Merge branch 'stable-2.14' into stable-2.15

* stable-2.14:
  Switch bazel version to 0.29.1

Change-Id: Ib06d7726a3ac0fc088750c7e4a31a59860ecc68d
diff --git a/.bazelproject b/.bazelproject
index 41bb27f..e3a7a9c 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -18,3 +18,6 @@
 java_language_level: 8
 
 workspace_type: java
+
+build_flags:
+  --javacopt=-g
diff --git a/.gitignore b/.gitignore
index d1ddc33..3f8388f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@
 /plugins/cookbook-plugin/
 /test_site
 /tools/format
+/.ijwb
diff --git a/.gitreview b/.gitreview
new file mode 100644
index 0000000..9344401
--- /dev/null
+++ b/.gitreview
@@ -0,0 +1,5 @@
+[gerrit]
+host=gerrit-review.googlesource.com
+scheme=https
+project=gerrit.git
+defaultbranch=stable-2.15
diff --git a/.mailmap b/.mailmap
index bd4d222..42b713c 100644
--- a/.mailmap
+++ b/.mailmap
@@ -11,6 +11,7 @@
 Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonymobile.com>
 Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
 Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
+Darrien Glasser <darrien@arista.com>                                                        darrien <darrien@arista.com>
 David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
 David Ostrovsky <david@ostrovsky.org>                                                       <david.ostrovsky@gmail.com>
 David Pursehouse <dpursehouse@collab.net>                                                   <david.pursehouse@sonymobile.com>
@@ -35,6 +36,7 @@
 Joel Dodge <dodgejoel@gmail.com>                                                            dodgejoel <dodgejoel@gmail.com>
 Johan Björk <jbjoerk@gmail.com>                                                             Johan Bjork <phb@spotify.com>
 JT Olds <hello@jtolds.com>                                                                  <jtolds@gmail.com>
+Lawrence Dubé <ldube@audiokinetic.com>                                                      <ldube@audiokinetic.com>
 Lei Sun <lei.sun01@sap.com>                                                                 LeiSun <lei.sun01@sap.com>
 Lincoln Oliveira Campos Do Nascimento <lincoln.oliveiracamposdonascimento@sonyericsson.com> lincoln <lincoln.oliveiracamposdonascimento@sonyericsson.com>
 Luca Milanesio <luca.milanesio@gmail.com>                                                   <luca@gitent-scm.com>
@@ -65,6 +67,7 @@
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@gmail.com>
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                Ulrik Sjolin <ulrik.sjolin@sonyericsson.com>
+Viktar Donich <viktard@google.com>                                                          viktard
 Yuxuan 'fishy' Wang <fishywang@google.com>                                                  Yuxuan Wang <fishywang@google.com>
 Zalán Blénessy <zalanb@axis.com>                                                            Zalan Blenessy <zalanb@axis.com>
 飞 李 <lifei@7v1.net>                                                                       lifei <lifei@7v1.net>
diff --git a/.settings/org.eclipse.jdt.ui.prefs b/.settings/org.eclipse.jdt.ui.prefs
index 3d5f5f6..18c15dd 100644
--- a/.settings/org.eclipse.jdt.ui.prefs
+++ b/.settings/org.eclipse.jdt.ui.prefs
@@ -2,4 +2,4 @@
 org.eclipse.jdt.ui.ignorelowercasenames=true
 org.eclipse.jdt.ui.ondemandthreshold=99
 org.eclipse.jdt.ui.staticondemandthreshold=99
-org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates/>
+org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates><template autoinsert\="true" context\="gettercomment_context" deleted\="false" description\="Comment for getter method" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.gettercomment" name\="gettercomment">/**\n * @return the ${bare_field_name}\n */</template><template autoinsert\="true" context\="settercomment_context" deleted\="false" description\="Comment for setter method" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.settercomment" name\="settercomment">/**\n * @param ${param} the ${bare_field_name} to set\n */</template><template autoinsert\="true" context\="constructorcomment_context" deleted\="false" description\="Comment for created constructors" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.constructorcomment" name\="constructorcomment">/**\n * ${tags}\n */</template><template autoinsert\="false" context\="filecomment_context" deleted\="false" description\="Comment for created Java files" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.filecomment" name\="filecomment">// Copyright (C) ${year} The Android Open Source Project\n//\n// Licensed under the Apache License, Version 2.0 (the "License");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http\://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an "AS IS" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.</template><template autoinsert\="true" context\="typecomment_context" deleted\="false" description\="Comment for created types" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.typecomment" name\="typecomment">/**\n * @author ${user}\n *\n * ${tags}\n */</template><template autoinsert\="true" context\="fieldcomment_context" deleted\="false" description\="Comment for fields" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.fieldcomment" name\="fieldcomment">/**\n * \n */</template><template autoinsert\="true" context\="methodcomment_context" deleted\="false" description\="Comment for non-overriding methods" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.methodcomment" name\="methodcomment">/**\n * ${tags}\n */</template><template autoinsert\="true" context\="overridecomment_context" deleted\="false" description\="Comment for overriding methods" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.overridecomment" name\="overridecomment">/* (non-Javadoc)\n * ${see_to_overridden}\n */</template><template autoinsert\="true" context\="delegatecomment_context" deleted\="false" description\="Comment for delegate methods" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.delegatecomment" name\="delegatecomment">/**\n * ${tags}\n * ${see_to_target}\n */</template><template autoinsert\="false" context\="newtype_context" deleted\="false" description\="Newly created files" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.newtype" name\="newtype">${filecomment}\n\n${package_declaration}\n\n${typecomment}\n${type_declaration}</template><template autoinsert\="false" context\="classbody_context" deleted\="false" description\="Code in new class type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.classbody" name\="classbody"/><template autoinsert\="true" context\="interfacebody_context" deleted\="false" description\="Code in new interface type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.interfacebody" name\="interfacebody">\n</template><template autoinsert\="true" context\="enumbody_context" deleted\="false" description\="Code in new enum type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.enumbody" name\="enumbody">\n</template><template autoinsert\="true" context\="annotationbody_context" deleted\="false" description\="Code in new annotation type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.annotationbody" name\="annotationbody">\n</template><template autoinsert\="false" context\="catchblock_context" deleted\="false" description\="Code in new catch blocks" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.catchblock" name\="catchblock">${exception_var}.printStackTrace();</template><template autoinsert\="false" context\="methodbody_context" deleted\="false" description\="Code in created method stubs" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.methodbody" name\="methodbody">${body_statement}</template><template autoinsert\="false" context\="constructorbody_context" deleted\="false" description\="Code in created constructor stubs" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.constructorbody" name\="constructorbody">${body_statement}</template><template autoinsert\="true" context\="getterbody_context" deleted\="false" description\="Code in created getters" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.getterbody" name\="getterbody">return ${field};</template><template autoinsert\="true" context\="setterbody_context" deleted\="false" description\="Code in created setters" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.setterbody" name\="setterbody">${field} \= ${param};</template></templates>
diff --git a/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch b/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch
deleted file mode 100644
index 3ccf5cd..0000000
--- a/0001-Replace-native-http-git-_archive-with-Skylark-rules.patch
+++ /dev/null
@@ -1,133 +0,0 @@
-Date: Wed, 30 May 2018 21:22:18 +0200
-Subject: [PATCH] Replace native {http,git}_archive with Skylark rules
-
-See [1] for more details.
-
-Test Plan:
-
-* Apply this CL on Bazel master: [2] and build bazel
-* Run with this custom built bazel version:
-
-  $ bazel test //javatests/...
-  $ bazel test //closure/...
-
-[1] https://groups.google.com/d/topic/bazel-discuss/dO2MHQLwJF0/discussion
-[2] https://bazel-review.googlesource.com/#/c/bazel/+/55932/
----
- closure/repositories.bzl | 23 ++++++++++++-----------
- 1 file changed, 12 insertions(+), 11 deletions(-)
-
-diff --git a/closure/repositories.bzl b/closure/repositories.bzl
-index 9b84a72..2816fb6 100644
---- closure/repositories.bzl
-+++ closure/repositories.bzl
-@@ -14,6 +14,7 @@
- 
- """External dependencies for Closure Rules."""
- 
-+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
- load("//closure/private:java_import_external.bzl", "java_import_external")
- load("//closure/private:platform_http_file.bzl", "platform_http_file")
- load("//closure:filegroup_external.bzl", "filegroup_external")
-@@ -405,7 +406,7 @@ def com_google_common_html_types():
-   )
- 
- def com_google_common_html_types_html_proto():
--  native.http_file(
-+  http_file(
-       name = "com_google_common_html_types_html_proto",
-       sha256 = "6ece202f11574e37d0c31d9cf2e9e11a0dbc9218766d50d211059ebd495b49c3",
-       urls = [
-@@ -633,7 +634,7 @@ def com_google_javascript_closure_compiler():
- 
- def com_google_javascript_closure_library():
-   # After updating: bazel run //closure/library:regenerate -- "$PWD"
--  native.new_http_archive(
-+  http_archive(
-       name = "com_google_javascript_closure_library",
-       urls = [
-           "https://mirror.bazel.build/github.com/google/closure-library/archive/v20180405.tar.gz",
-@@ -658,7 +659,7 @@ def com_google_jsinterop_annotations():
- 
- def com_google_protobuf():
-   # Note: Protobuf 3.6.0+ is going to use C++11
--  native.http_archive(
-+  http_archive(
-       name = "com_google_protobuf",
-       strip_prefix = "protobuf-3.5.1",
-       sha256 = "826425182ee43990731217b917c5c3ea7190cfda141af4869e6d4ad9085a740f",
-@@ -669,7 +670,7 @@ def com_google_protobuf():
-   )
- 
- def com_google_protobuf_js():
--  native.new_http_archive(
-+  http_archive(
-       name = "com_google_protobuf_js",
-       urls = [
-           "https://mirror.bazel.build/github.com/google/protobuf/archive/v3.5.1.tar.gz",
-@@ -722,7 +723,7 @@ def com_google_template_soy():
-   )
- 
- def com_google_template_soy_jssrc():
--  native.new_http_archive(
-+  http_archive(
-       name = "com_google_template_soy_jssrc",
-       sha256 = "c76ab4cb6e46a7c76336640b3c40d6897b420209a6c0905cdcd32533dda8126a",
-       urls = [
-@@ -757,7 +758,7 @@ def com_squareup_javapoet():
-   )
- 
- def fonts_noto_hinted_deb():
--  native.http_file(
-+  http_file(
-       name = "fonts_noto_hinted_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fonts-noto/fonts-noto-hinted_20161116-1_all.deb",
-@@ -767,7 +768,7 @@ def fonts_noto_hinted_deb():
-   )
- 
- def fonts_noto_mono_deb():
--  native.http_file(
-+  http_file(
-       name = "fonts_noto_mono_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fonts-noto/fonts-noto-mono_20161116-1_all.deb",
-@@ -801,7 +802,7 @@ def javax_inject():
-   )
- 
- def libexpat_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libexpat_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/e/expat/libexpat1_2.1.0-6+deb8u3_amd64.deb",
-@@ -811,7 +812,7 @@ def libexpat_amd64_deb():
-   )
- 
- def libfontconfig_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libfontconfig_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/fontconfig/libfontconfig1_2.11.0-6.3+deb8u1_amd64.deb",
-@@ -821,7 +822,7 @@ def libfontconfig_amd64_deb():
-   )
- 
- def libfreetype_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libfreetype_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/f/freetype/libfreetype6_2.5.2-3+deb8u1_amd64.deb",
-@@ -831,7 +832,7 @@ def libfreetype_amd64_deb():
-   )
- 
- def libpng_amd64_deb():
--  native.http_file(
-+  http_file(
-       name = "libpng_amd64_deb",
-       urls = [
-           "https://mirror.bazel.build/http.us.debian.org/debian/pool/main/libp/libpng/libpng12-0_1.2.50-2+deb8u2_amd64.deb",
--- 
-2.16.3
-
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index e55378f..67a4c13 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -220,6 +220,7 @@
 thus `^refs/heads/.*/name` will fail because `refs/heads//name`
 is not a valid reference, but `^refs/heads/.+/name` will work.
 
+[[sharded-user-id]]
 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
@@ -404,29 +405,6 @@
 link:user-upload.html#push_create[Upload changes] page.
 
 
-==== refs/publish/*
-
-`+refs/publish/*+` is an alternative name to `+refs/for/*+` when pushing new changes
-and patch sets.
-
-
-==== refs/drafts/*
-
-Push to `+refs/drafts/*+` creates a change like push to `+refs/for/*+`, except the
-resulting change remains hidden from public review.  You then have the option
-of adding individual reviewers before making the change public to all.  The
-change page will have a 'Publish' button which allows you to convert individual
-draft patch sets of a change into public patch sets for review.
-
-To block push permission to `+refs/drafts/*+` the following permission rule can
-be configured:
-
-----
-  [access "refs/drafts/*"]
-    push = block group Anonymous Users
-----
-
-
 [[access_categories]]
 == Access Categories
 
@@ -434,7 +412,6 @@
 within projects, enabling functionality for that group's members.
 
 
-
 [[category_abandon]]
 === Abandon
 
@@ -497,7 +474,7 @@
 
 Deletion of references is also possible if `Push` with the force option
 is granted, however that includes the permission to fast-forward and
-force-update references to exiting and new commits. Being able to push
+force-update references to existing and new commits. Being able to push
 references for new commits is bad if bypassing of code review must be
 prevented.
 
@@ -559,8 +536,6 @@
 configuration.  Users who are members of an owner group can:
 
 * Change the project description
-* Create a branch via the ssh command link:cmd-create-branch.html['create-branch']
-* Create/delete a branch through the web UI
 * Grant/revoke any access rights, including `Owner`
 
 To get SSH branch access project owners must grant an access right to a group
@@ -850,35 +825,14 @@
 Note that this permission is named `submitAs` in the `project.config`
 file.
 
-[[category_view_drafts]]
-=== View Drafts
+[[category_view_private_changes]]
+=== View Private Changes
 
-This category permits users to view draft changes uploaded by other
-users.
+This category permits users to view all private changes.
 
 The change owner and any explicitly added reviewers can always see
-draft changes (even without having the `View Drafts` access right
-assigned).
-
-
-[[category_publish_drafts]]
-=== Publish Drafts
-
-This category permits users to publish draft changes uploaded by other
-users.
-
-The change owner can always publish draft changes (even without having
-the `Publish Drafts` access right assigned).
-
-
-[[category_delete_drafts]]
-=== Delete Drafts
-
-This category permits users to delete draft changes uploaded by other
-users.
-
-The change owner can always delete draft changes (even without having
-the `Delete Drafts` access right assigned).
+private changes (even without having the `View Private Changes` access
+right assigned).
 
 
 [[category_delete_own_changes]]
@@ -916,8 +870,8 @@
 [[category_edit_hashtags]]
 === Edit Hashtags
 
-This category permits users to add or remove hashtags on a change that
-is uploaded for review.
+This category permits users to add or remove
+link:intro-user.html#hashtags[hashtags] on a change that is uploaded for review.
 
 The change owner, branch owners, project owners, and site administrators
 can always edit or remove hashtags (even without having the `Edit Hashtags`
@@ -958,13 +912,7 @@
 If it's desired to have the possibility to upload temporarily hidden
 changes there's a specific permission for that.  This enables someone
 to add specific reviewers for early feedback before making the change
-publicly visible.  If you want to allow others than the owners to
-publish a draft you also need to grant them `Publish Drafts`.
-
-Optional access rights to grant:
-
-* xref:category_push[`Push`] to 'refs/drafts/*'
-* xref:category_publish_drafts[`Publish Drafts`] to 'refs/heads/*'
+publicly visible.
 
 
 [[examples_developer]]
diff --git a/Documentation/cmd-flush-caches.txt b/Documentation/cmd-flush-caches.txt
index 4716f3b..9ba4808 100644
--- a/Documentation/cmd-flush-caches.txt
+++ b/Documentation/cmd-flush-caches.txt
@@ -60,7 +60,6 @@
 ----
 	$ ssh -p 29418 review.example.com gerrit flush-caches --list
 	accounts
-	accounts_byemail
 	diff
 	groups
 	ldap_groups
diff --git a/Documentation/cmd-review.txt b/Documentation/cmd-review.txt
index 8f40d6c..7ee7b83 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -15,13 +15,11 @@
   [--abandon | --restore]
   [--rebase]
   [--move <BRANCH>]
-  [--publish]
   [--json | -j]
-  [--delete]
   [--verified <N>] [--code-review <N>]
   [--label Label-Name=<N>]
   [--tag TAG]
-  {COMMIT | CHANGEID,PATCHSET}...
+  {COMMIT | CHANGENUMBER,PATCHSET}...
 --
 
 == DESCRIPTION
@@ -66,7 +64,7 @@
 	Read review input json from stdin. See
 	link:rest-api-changes.html#review-input[ReviewInput] entity for the
 	format.
-	(option is mutually exclusive with --submit, --restore, --publish, --delete,
+	(option is mutually exclusive with --submit, --restore,
 	--abandon, --message, --rebase and --move)
 
 --notify::
@@ -88,7 +86,7 @@
 
 --abandon::
 	Abandon the specified change(s).
-	(option is mutually exclusive with --submit, --restore, --publish, --delete,
+	(option is mutually exclusive with --submit, --restore,
 	--rebase, --move and --json)
 
 --restore::
@@ -97,7 +95,7 @@
 
 --rebase::
 	Rebase the specified change(s).
-	(option is mutually exclusive with --abandon, --submit, --delete and --json)
+	(option is mutually exclusive with --abandon, --submit and --json)
 
 --move::
 	Move the specified change(s).
@@ -106,19 +104,9 @@
 --submit::
 -s::
 	Submit the specified patch set(s) for merging.
-	(option is mutually exclusive with --abandon, --publish --delete, --rebase
+	(option is mutually exclusive with --abandon, --rebase
 	and --json)
 
---publish::
-	Publish the specified draft patch set(s).
-	(option is mutually exclusive with --submit, --restore, --abandon, --delete
-	and --json)
-
---delete::
-	Delete the specified draft patch set(s).
-	(option is mutually exclusive with --submit, --restore, --abandon, --publish,
-	--rebase and --json)
-
 --code-review::
 --verified::
 	Set the label to the value 'N'.  The exact option names
@@ -130,24 +118,19 @@
 	Votes that are not permitted for the user are silently ignored.
 
 --label::
-	Set a label by name to the value 'N'.  Invalid votes (invalid label
-	or invalid value) and votes that are not permitted for the user are
-	silently ignored.
-
---strict-labels::
-	Require ability to vote on all specified labels before reviewing change.
-	If the vote is invalid (invalid label or invalid name), the vote is not
-	permitted for the user, or the vote is on an outdated or closed patch set,
-	return an error instead of silently discarding the vote.
+	Set a label by name to the value 'N'. The ability to vote on all specified
+	labels is required. If the vote is invalid (invalid label or invalid name),
+	the vote is not 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.
+	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 that contain TAG values with 'autogenerated:' prefix
+	can be filtered out in the web UI.
+	Note that to apply different tags on different votes/comments, multiple
+	invocations of the SSH command are required.
 
 == ACCESS
 Any user who has SSH access to Gerrit.
@@ -162,16 +145,21 @@
 	$ ssh -p 29418 review.example.com gerrit review --verified +1 c0ff33
 ----
 
+Approve the change with change number 8242 and patch set 2 as "Code-Review +2"
+----
+	$ ssh -p 29418 review.example.com gerrit review --code-review +2 8242,2
+----
+
 Vote on the project specific label "mylabel":
 ----
-	$ ssh -p 29418 review.example.com gerrit review --label mylabel=+1 c0ff33
+	$ ssh -p 29418 review.example.com gerrit review --label mylabel=+1 8242,2
 ----
 
 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
+	$ ssh -p 29418 review.example.com gerrit review -m '"Build Successful"' 8242,2
 ----
 
 Mark the unmerged commits both "Verified +1" and "Code-Review +2" and
@@ -187,7 +175,7 @@
 
 Abandon an active change:
 ----
-  $ ssh -p 29418 review.example.com gerrit review --abandon c0ff33
+  $ ssh -p 29418 review.example.com gerrit review --abandon 8242,2
 ----
 
 == SEE ALSO
diff --git a/Documentation/cmd-set-account.txt b/Documentation/cmd-set-account.txt
index 884c8cc..276306e 100644
--- a/Documentation/cmd-set-account.txt
+++ b/Documentation/cmd-set-account.txt
@@ -12,6 +12,7 @@
   [--preferred-email <EMAIL>]
   [--add-ssh-key - | <KEY>]
   [--delete-ssh-key - | <KEY> | ALL]
+  [--generate-http-password]
   [--http-password <PASSWORD>]
   [--clear-http-password] <USER>
 --
@@ -25,8 +26,9 @@
 verification step we force within the UI.
 
 == ACCESS
-Caller must be a member of the privileged 'Administrators' group,
-or have been granted
+Users can call this to update their own accounts. To update a different
+account, a caller must be a member of the privileged 'Administrators'
+group, or have been granted
 link:access-control.html#capability_modifyAccount[the 'Modify Account' global capability].
 For security reasons only the members of the privileged 'Administrators'
 group can add or delete SSH keys for a user.
@@ -93,6 +95,11 @@
     May be supplied more than once to delete multiple SSH
     keys in a single command execution.
 
+--generate-http-password::
+    Generate a new random HTTP password for the user account
+    similar to the web ui. The password will be output to the
+    user on success with a line: `New password: <PASSWORD>`.
+
 --http-password::
     Set the HTTP password for the user account.
 
diff --git a/Documentation/cmd-show-caches.txt b/Documentation/cmd-show-caches.txt
index 59abc1c..6a1f554 100644
--- a/Documentation/cmd-show-caches.txt
+++ b/Documentation/cmd-show-caches.txt
@@ -58,16 +58,15 @@
                                   |   Mem   Disk   Space|         |Mem  Disk|
   --------------------------------+---------------------+---------+---------+
     accounts                      |  4096               |   3.4ms | 99%     |
-    accounts_byemail              |  1024               |   7.6ms | 98%     |
-    accounts_byname               |  4096               |  11.3ms | 99%     |
     adv_bases                     |                     |         |         |
     changes                       |                     |  27.1ms |  0%     |
     groups                        |  5646               |  11.8ms | 97%     |
-    groups_byinclude              |   230               |   2.4ms | 62%     |
+    groups_bymember               |                     |         |         |
     groups_byname                 |                     |         |         |
+    groups_bysubgroup             |   230               |   2.4ms | 62%     |
     groups_byuuid                 |  5612               |  29.2ms | 99%     |
     groups_external               |     1               |   1.5s  | 98%     |
-    groups_members                |  5714               |  19.7ms | 99%     |
+    groups_subgroups              |  5714               |  19.7ms | 99%     |
     ldap_group_existence          |                     |         |         |
     ldap_groups                   |   650               | 680.5ms | 99%     |
     ldap_groups_byinclude         |  1024               |         | 83%     |
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 1fdf3a8..557c777 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -46,7 +46,7 @@
 
 ----
   $ ssh -p 29418 review.example.com gerrit stream-events \
-      -s draft-published -s patchset-created -s ref-replicated
+      -s patchset-created -s ref-replicated
 ----
 
 == SCHEMA
@@ -153,21 +153,6 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
-=== Draft Published
-
-Sent when a draft change has been published.
-
-type:: "draft-published"
-
-change:: link:json.html#change[change attribute]
-
-patchSet:: link:json.html#patchSet[patchSet attribute]
-
-uploader:: link:json.html#account[account attribute]
-
-eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
-created.
-
 === Dropped Output
 
 Sent to notify a client that events have been dropped.
@@ -176,7 +161,8 @@
 
 === Hashtags Changed
 
-Sent when the hashtags have been added to or removed from a change.
+Sent when the link:intro-user.html#hashtags[hashtags] have been added to or
+removed from a change.
 
 type:: "hashtags-changed"
 
@@ -211,11 +197,6 @@
 Sent when a new change has been uploaded, or a new patch set has been uploaded
 to an existing change.
 
-Note that this event is also sent for changes or patch sets uploaded as draft,
-but is only visible to the change owner, any existing reviewers, and users who
-belong to a group that is granted the
-link:access-control.html#category_view_drafts[View Drafts] capability.
-
 type:: "patchset-created"
 
 change:: link:json.html#change[change attribute]
@@ -291,6 +272,37 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+=== Work In Progress State Changed
+
+Sent when the link:intro-user.html#wip[WIP] state of the change has changed.
+
+type:: wip-state-changed
+
+change:: link:json.html#change[change attribute]
+
+patchSet:: link:json.html#patchSet[patchSet attribute]
+
+changer:: link:json.html#account[account attribute]
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
+=== Private State Changed
+
+Sent when the link:intro-user.html#private-changes[private] state of the
+change has changed.
+
+type:: private-state-changed
+
+change:: link:json.html#change[change attribute]
+
+patchSet:: link:json.html#patchSet[patchSet attribute]
+
+changer:: link:json.html#account[account attribute]
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 === Vote Deleted
 
 Sent when a vote was removed from a change.
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
new file mode 100644
index 0000000..f24d515
--- /dev/null
+++ b/Documentation/concept-changes.txt
@@ -0,0 +1,211 @@
+= Changes
+
+A change represents a single commit under review. Each change is identified
+by a <<change-id>>.
+
+Multiple git commits can share the same Change-Id, allowing you to update a
+change as you receive feedback through the code review process. In Gerrit,
+commits that share the same Change-Id are referred to as _patch sets_. When a
+change is approved, only the latest version of a commit is submitted to the
+repository.
+
+You can view a specific change using Gerrit's Review screen. This screen
+provides the following information for each change:
+
+* Current and previous patch sets
+* <<Change properties>>, such as owner, project, and target branch
+* link:CONCEPT-comments.html[Comments]
+* Votes on link:config-labels.html[Review Labels]
+* The <<change-id>>
+
+[[change-properties]]
+== Change properties
+
+When you open a change in Gerrit, the Review screen displays a number of
+properties about that change.
+
+.Change Properties
+|===
+|Property|Description
+
+|Updated
+|The date on which the change was last updated.
+
+|Owner
+|The contributor who created the change.
+
+|Assignee
+|The contributor responsible for the change. Often used when a change has
+mulitple reviewers to identify the individual responsible for final approval.
+
+|Reviewers
+|A list of one or more contributors responsible for reviewing the change.
+
+|CC
+|A list of one or more contributors who are kept informed about the change, but
+are not required to review it.
+
+|Project
+|The name of the Gerrit project.
+
+|Branch
+|The branch on which the change was made.
+
+|Topic
+|An optional topic.
+
+|Strategy
+|The <<submit-strategies,submit strategy>> for the change.
+
+|Code Review
+|Displays the Code Review status for the change.
+
+|===
+
+In addition, Gerrit displays the status of any additional labels, such as
+the Verified label, that have been configured for the server. See
+link:config-labels.html[Review Labels] for more information.
+
+[[change-message]]
+== Change Message
+
+Next to the list of change properties is the change message. This message
+contains user-supplied information regarding what the change does. To modify
+the change message, click the *Edit* link.
+
+By default, the change message contains the Change-Id. This ID contains a
+permanent link to a search for that Change-Id in Gerrit.
+
+[[related-changes]]
+== Related Changes
+
+In some cases, a change may be dependent on another change. These changes are
+listed next to the change message. These related changes are grouped together in
+several categories, including:
+
+* Relation Chain. These changes are related by parent-child relationships,
+  regardless of <<topic,topic>>.
+* Merge Conflicts. These are changes in which there is a merge conflict with
+  the current change.
+* Submitted Together. These are changes that share the same <<topic,topic>>.
+
+An arrow indicates the change you are currently viewing.
+
+[[topic]]
+== Topics
+
+Changes can be grouped by topics. Topics make it easier to find related changes
+by using the topic search operator. Changes with the same topic also appear in
+the *Relation Chain* section of the Review screen.
+
+Grouping changes by topics can be helpful when you have several changes that,
+when combined, implement a feature.
+
+Assigning a topic to a change can be done in the change screen or through a `git
+push` command.
+
+[[submit-strategies]]
+== Submit strategies
+
+Each project in Gerrit can employ a specific submit strategy. This strategy is
+listed in the change properties section of the Review screen.
+
+The following table lists the supported submit strategies.
+
+.Submit Strategies
+|===
+|Strategy|Description
+
+|Fast Forward Only
+|No merge commits are produced. All merges must be handled on the client, before
+submitting the change.
+
+To submit a change, the change must be a strict superset of the destination
+branch.
+
+|Merge If Necessary
+|The default submit strategy. If the change being submitted is a strict superset
+of the destination branch, then the branch is fast-forwarded to the change. If
+not, a merge commit is automatically created at submit time. This is identical
+to the `git merge --ff` command.
+
+|Always Merge
+|Always produce a merge commit, even if the change is a strict superset of the
+destination branch. This is identical to the `git merge --no-ff` command.
+It is often used when users of the project want to be able to read the history
+of submits by running the `git log --first-parent` command.
+
+|Cherry Pick
+|Always cherry pick the patch set, ignoring the parent lineage and instead
+creating a new commit on top of the current branch.
+
+When cherry picking a change, Gerrit automatically appends a short summary of
+the change's approvals and a link back to the change. The committer header is
+also set to the submitter, while the author header retains the original patch
+set author.
+
+NOTE: Gerrit ignores dependencies between changes when using this submit type
+unless `change.submitWholeTopic` is enabled and depending changes share the same
+topic. This means submitters must remember to submit changes in the right order
+when using this submit type.
+
+|Rebase if Necessary
+|If the change being submitted is a strict superset of the destination branch,
+the branch is fast-forwarded to the change. If not, the change is automatically
+rebased and the branch is fast-forwarded to the change.
+
+|Rebase Always
+|Similar to Rebase If Necessary, but creates a new patch set even if fast
+forward is possible. This strategy is also similar to Cherry Pick; however,
+Rebase Always does not ignore dependencies.
+
+|===
+
+Any project owner can use the Project screen to modify the method Gerrit uses
+to submit a change.
+
+[[change-id]]
+== Change-Id
+
+Gerrit uses a Change-Id to identify which patch sets belong to the same review.
+For example, you make a change to a project. A reviewer supplies some feedback,
+which you address in a second commit. By assigning the same Change-Id to both
+commits, Gerrit can attach those commits to the same change.
+
+Change-Ids are appended to the end of a commit message, and resemble the
+following:
+
+....
+commit 29a6bb1a059aef021ac39d342499191278518d1d
+Author: A. U. Thor <author@example.com>
+Date: Thu Aug 20 12:46:50 2009 -0700
+
+    Improve foo widget by attaching a bar.
+
+    We want a bar, because it improves the foo by providing more
+    wizbangery to the dowhatimeanery.
+
+    Bug: #42
+    Change-Id: Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
+    Signed-off-by: A. U. Thor <author@example.com>
+    CC: R. E. Viewer <reviewer@example.com>
+....
+
+Gerrit requires that the Change-Id is in the footer (last paragraph) of a
+commit message. It can be combined with a Signed-off-by, CC, or other lines. For
+instance, the previous example has a Change-Id, along with a Signed-off-by and
+CC line.
+
+Notice that the Change-Id is similar to the commit id. To avoid confusing the
+two, a Change-Id typically begins with an `I`.
+
+While there are several ways you can add a Change-Id, the standard
+method uses git's link:cmd-hook-commit-msg.html[commit-msg hook]
+to automatically add the Change-Id to each new commit.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/concept-patch-sets.txt b/Documentation/concept-patch-sets.txt
new file mode 100644
index 0000000..8609afd
--- /dev/null
+++ b/Documentation/concept-patch-sets.txt
@@ -0,0 +1,99 @@
+= Patch Sets
+
+As described in link:concept-changes.html[Changes], a change represents a single
+commit under review. Each change is assigned a
+link:concept-changes.html#change-id[Change-Id].
+
+It is very common to amend a commit during the code review process. Gerrit uses
+the Change-Id to associate each iteration of the commit with the same change.
+These iterations of a commit are referred to as _patch sets_. When a change is
+approved, only the latest version of a commit is submitted to the repository.
+
+NOTE: It is also possible to copy a Change-Id to a completely new commit. This
+is useful in situations where you want to keep the discussion around a change,
+but also need to completely modify your approach.
+
+== File List
+
+When you open a change in Gerrit, a list of affected files appears in the
+file list, located in the middle of the Review screen. This table displays
+the following information for each file:
+
+* A checkbox, indicating the file has been reviewed
+* The type of modification
+* The path and name of the file
+* The number of added lines and or deleted lines
+
+[[file-modifications]]
+== File modifications
+
+Each file in a patch set has a letter next to it, indicating the type of
+modification for that file. The following table lists the types of
+modifications.
+
+.Types of file modifications
+|===
+|Letter|Modification Type|Definition
+
+|M
+|Modification
+|The file existed before this change and is modified.
+
+|A
+|Added
+|The file is newly added.
+
+|D
+|Deleted
+|The file is deleted.
+
+|R
+|Renamed
+|The file is renamed.
+
+|C
+|Copied
+|The file is new and is copied from an existing file.
+
+|===
+
+If the status is *R* (Renamed) or *C* (Copied), the file list also displays the
+original name of the file below the patch set file.
+
+== Views
+
+By default, Gerrit displays the latest patch set for a given change. You can
+view previous versions of a patch set by selecting from the *Patch Set*
+drop-down list.
+
+== Diffs
+
+Clicking a file in the file list opens the Diff screen. By default, this
+screen displays a diff between the latest patch set's version of a file and the
+current version of that file in the repository. You can also open a diff within
+the Review screen by clicking the blue triangle located in the same row as the
+file. To show the diffs of all files in the Review screen, click the *Show
+Diffs* link, located at the top of the file list.
+
+You can diff between other patch sets by selecting a patch set number from the
+*Diff Against* drop-down list.
+
+== Description
+
+Each change in Gerrit must have a change description. This change description
+comes from the commit message and becomes part of the history of the project.
+
+In addition to the change description, you can add a description for a specific
+patch set. This description is intended to help guide reviewers as a change
+evolves, such as "Added more unit tests." Unlike the change description, a patch
+set description does not become a part of the project's history.
+
+To add a patch set description, click *Add a patch set description*, located in
+the file list.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/concept-refs-for-namespace.txt b/Documentation/concept-refs-for-namespace.txt
new file mode 100644
index 0000000..c8776ae
--- /dev/null
+++ b/Documentation/concept-refs-for-namespace.txt
@@ -0,0 +1,72 @@
+= The refs/for namespace
+
+When pushing a new or updated commit to Gerrit, you push that commit using a
+link:https://www.kernel.org/pub/software/scm/git/docs/gitglossary.html#def_ref[reference],
+in the `refs/for` namespace. This reference must also define
+the target branch, such as `refs/for/[BRANCH_NAME]`.
+
+For example, to create a new change on the master branch, you would use the
+following command:
+
+....
+git push origin HEAD:refs/for/master
+....
+
+The `refs/for/[BRANCH_NAME]` syntax allows Gerrit to differentiate between
+commits that are pushed for review and commits that are pushed directly into
+the repository.
+
+Gerrit supports using either the full name or the short name for a branch. For
+instance, this command:
+
+....
+git commit
+git push origin HEAD:refs/for/master
+....
+
+is the same as:
+
+....
+git commit
+git push origin HEAD:refs/for/refs/heads/master
+....
+
+Gerrit uses the `refs/for/` prefix to map the concept of "Pushing for Review" to
+the git protocol. For the git client, it looks like every push goes to the same
+branch, such as `refs/for/master`.  In fact, for each commit pushed to this ref,
+Gerrit creates a new ref under a `refs/changes/` namespace, which Gerrit uses
+to track these commits. These references use the following format:
+
+....
+refs/changes/[CD]/[ABCD]/[EF]
+....
+
+Where:
+
+* [CD] is the last two digits of the change number
+* [ABCD] is the change number
+* [EF] is the patch set number
+
+For example:
+
+....
+refs/changes/20/884120/1
+....
+
+You can use the change reference to fetch its corresponding commit:
+
+....
+git fetch https://[GERRIT_SERVER_URL]/[PROJECT] refs/changes/[XX]/[YYYY]/[ZZ] \
+&& git checkout FETCH_HEAD
+....
+
+NOTE: The fetch command can be copied from the
+link:user-review-ui.html#download[download command] in the Change screen.
+
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
new file mode 100644
index 0000000..35e6800
--- /dev/null
+++ b/Documentation/config-accounts.txt
@@ -0,0 +1,423 @@
+= Gerrit Code Review - Accounts
+
+== Overview
+
+Starting from 2.15 Gerrit accounts are fully stored in
+link:note-db.html[NoteDb].
+
+The account data consists of a sequence number (account ID), account
+properties (full name, preferred email, registration date, status,
+inactive flag), preferences (general, diff and edit preferences),
+project watches, SSH keys, external IDs, starred changes and reviewed
+flags.
+
+Most account data is stored in a special link:#all-users[All-Users]
+repository, which has one branch per user. Within the user branch there
+are Git config files for the link:#account-properties[
+account properties], the link:#preferences[account preferences] and the
+link:#project-watches[project watches]. In addition there is an
+`authorized_keys` file for the link:#ssh-keys[SSH keys] that follows
+the standard OpenSSH file format.
+
+The account data in the user branch is versioned and the Git history of
+this branch serves as an audit log.
+
+The link:#external-ids[external IDs] are stored as Git Notes inside the
+`All-Users` repository in the `refs/meta/external-ids` notes branch.
+Storing all external IDs in a notes branch ensures that each external
+ID is only used once.
+
+The link:#starred-changes[starred changes] are represented as
+independent refs in the `All-Users` repository. They are not stored in
+the user branch, since this data doesn't need versioning.
+
+The link:#reviewed-flags[reviewed flags] are not stored in Git, but are
+persisted in a database table. This is because there is a high volume
+of reviewed flags and storing them in Git would be inefficient.
+
+Since accessing the account data in Git is not fast enough for account
+queries, e.g. when suggesting reviewers, Gerrit has a
+link:#account-index[secondary index for accounts].
+
+[[all-users]]
+== `All-Users` repository
+
+The `All-Users` repository is a special repository that only contains
+user-specific information. It contains one branch per user. The user
+branch is formatted as `refs/users/CD/ABCD`, where `CD/ABCD` is the
+link:access-control.html#sharded-user-id[sharded account ID], e.g. the
+user branch for account `1000856` is `refs/users/56/1000856`. The
+account IDs in the user refs are sharded so that there is a good
+distribution of the Git data in the storage system.
+
+A user branch must exist for each account, as it represents the
+account. The files in the user branch are all optional. This means
+having a user branch with a tree that is completely empty is also a
+valid account definition.
+
+Updates to the user branch are done through the
+link:rest-api-accounts.html[Gerrit REST API], but users can also
+manually fetch their user branch and push changes back to Gerrit. On
+push the user data is evaluated and invalid user data is rejected.
+
+To hide the implementation detail of the sharded account ID in the ref
+name Gerrit offers a magic `refs/users/self` ref that is automatically
+resolved to the user branch of the calling user. The user can then use
+this ref to fetch from and push to the own user branch. E.g. if user
+`1000856` pushes to `refs/users/self`, the branch
+`refs/users/56/1000856` is updated. In Gerrit `self` is an established
+term to refer to the calling user (e.g. in change queries). This is why
+the magic ref for the own user branch is called `refs/users/self`.
+
+A user branch should only be readable and writeable by the user to whom
+the account belongs. To assign permissions on the user branches the
+normal branch permission system is used. In the permission system the
+user branches are specified as `refs/users/${shardeduserid}`. The
+`${shardeduserid}` variable is resolved to the sharded account ID. This
+variable is used to assign default access rights on all user branches
+that apply only to the owning user. The following permissions are set
+by default when a Gerrit site is newly installed or upgraded to a
+version which supports user branches:
+
+.All-Users project.config
+----
+[access "refs/users/${shardeduserid}"]
+  exclusiveGroupPermissions = read push submit
+  read = group Registered Users
+  push = group Registered Users
+  label-Code-Review = -2..+2 group Registered Users
+  submit = group Registered Users
+----
+
+The user branch contains several files with account data which are
+described link:#account-data-in-user-branch[below].
+
+In addition to the user branches the `All-Users` repository also
+contains a branch for the link:#external-ids[external IDs] and special
+refs for the link:#starred-changes[starred changes].
+
+Also the next available value of the link:#account-sequence[account
+sequence] is stored in the `All-Users` repository.
+
+[[account-index]]
+== Account Index
+
+There are several situations in which Gerrit needs to query accounts,
+e.g.:
+
+* For sending email notifications to project watchers.
+* For reviewer suggestions.
+
+Accessing the account data in Git is not fast enough for account
+queries, since it requires accessing all user branches and parsing
+all files in each of them. To overcome this Gerrit has a secondary
+index for accounts. The account index is either based on
+link:config-gerrit.html#index.type[Lucene or Elasticsearch].
+
+Via the link:rest-api-accounts.html#query-account[Query Account] REST
+endpoint link:user-search-accounts.html[generic account queries] are
+supported.
+
+Accounts are automatically reindexed on any update. The
+link:rest-api-accounts.html#index-account[Index Account] REST endpoint
+allows to reindex an account manually. In addition the
+link:pgm-reindex.html[reindex] program can be used to reindex all
+accounts offline.
+
+[[account-data-in-user-branch]]
+== Account Data in User Branch
+
+A user branch contains several Git config files with the account data:
+
+* `account.config`:
++
+Stores the link:#account-properties[account properties].
+
+* `preferences.config`:
++
+Stores the link:#preferences[user preferences] of the account.
+
+* `watch.config`:
++
+Stores the link:#project-watches[project watches] of the account.
+
+In addition it contains an
+link:https://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys[
+authorized_keys] file with the link:#ssh-keys[SSH keys] of the account.
+
+[[account-properties]]
+=== Account Properties
+
+The account properties are stored in the user branch in the
+`account.config` file:
+
+----
+[account]
+  fullName = John Doe
+  preferredEmail = john.doe@example.com
+  status = OOO
+  active = false
+----
+
+For active accounts the `active` parameter can be omitted.
+
+The registration date is not contained in the `account.config` file but
+is derived from the timestamp of the first commit on the user branch.
+
+When users update their account properties by pushing to the user
+branch, it is verified that the preferred email exists in the external
+IDs.
+
+Users are not allowed to flip the active value themselves; only
+administrators and users with the
+link:access-control.html#capability_modifyAccount[Modify Account]
+global capability are allowed to change it.
+
+Since all data in the `account.config` file is optional the
+`account.config` file may be absent from some user branches.
+
+[[preferences]]
+=== Preferences
+
+The account properties are stored in the user branch in the
+`preferences.config` file. There are separate sections for
+link:intro-user.html#preferences[general],
+link:user-review-ui.html#diff-preferences[diff] and edit preferences:
+
+----
+[general]
+  showSiteHeader = false
+[diff]
+  hideTopMenu = true
+[edit]
+  lineLength = 80
+----
+
+The parameter names match the names that are used in the preferences REST API:
+
+* link:rest-api-accounts.html#preferences-info[General Preferences]
+* link:rest-api-accounts.html#diff-preferences-info[Diff Preferences]
+* link:rest-api-accounts.html#edit-preferences-info[Edit Preferences]
+
+If the value for a preference is the same as the default value for this
+preference, it can be omitted in the `preferences.config` file.
+
+Defaults for general and diff preferences that apply for all accounts
+can be configured in the `refs/users/default` branch in the `All-Users`
+repository.
+
+[[project-watches]]
+=== Project Watches
+
+Users can configure watches on projects to receive email notifications
+for changes of that project.
+
+A watch configuration consists of the project name and an optional
+filter query. If a filter query is specified, email notifications will
+be sent only for changes of that project that match this query.
+
+In addition, each watch configuration can contain a list of
+notification types that determine for which events email notifications
+should be sent. E.g. a user can configure that email notifications
+should only be sent if a new patch set is uploaded and when the change
+gets submitted, but not on other events.
+
+Project watches are stored in a `watch.config` file in the user branch:
+
+----
+[project "foo"]
+  notify = * [ALL_COMMENTS]
+  notify = branch:master [ALL_COMMENTS, NEW_PATCHSETS]
+  notify = branch:master owner:self [SUBMITTED_CHANGES]
+----
+
+The `watch.config` file has one project section for all project watches
+of a project. The project name is used as subsection name and the
+filters with the notification 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
+"<filter> [<comma-separated-list-of-notification-types>]". The
+supported notification types are described in the
+link:user-notify.html#notify.name.type[Email Notifications documentation].
+
+For a change event, a notification will be sent if any `notify` value
+of the corresponding project has both a filter that matches the change
+and a notification type that matches the event.
+
+In order to send email notifications on change events, Gerrit needs to
+find all accounts that watch the corresponding project. To make this
+lookup fast the secondary account index is used. The account index
+contains a repeated field that stores the projects that are being
+watched by an account. After the accounts that watch the project have
+been retrieved from the index, the complete watch configuration is
+available from the account cache and Gerrit can check if any watch
+matches the change and the event.
+
+[[ssh-keys]]
+=== SSH Keys
+
+SSH keys are stored in the user branch in an `authorized_keys` file,
+which is the
+link:https://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys[
+standard OpenSSH file format] for storing SSH keys:
+
+----
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqSuJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5Tw== john.doe@example.com
+# DELETED
+# INVALID ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDm5yP7FmEoqzQRDyskX+9+N0q9GrvZeh5RG52EUpE4ms/Ujm3ewV1LoGzc/lYKJAIbdcZQNJ9+06EfWZaIRA3oOwAPe1eCnX+aLr8E6Tw2gDMQOGc5e9HfyXpC2pDvzauoZNYqLALOG3y/1xjo7IH8GYRS2B7zO/Mf9DdCcCKSfw== john.doe@example.com
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCaS7RHEcZ/zjl9hkWkqnm29RNr2OQ/TZ5jk2qBVMH3BgzPsTsEs+7ag9tfD8OCj+vOcwm626mQBZoR2e3niHa/9gnHBHFtOrGfzKbpRjTWtiOZbB9HF+rqMVD+Dawo/oicX/dDg7VAgOFSPothe6RMhbgWf84UcK5aQd5eP5y+tQ== john.doe@example.com
+----
+
+When the SSH API is used, Gerrit needs an efficient way to lookup SSH
+keys by username. Since the username can be easily resolved to an
+account ID (via the account cache), accessing the SSH keys in the user
+branch is fast.
+
+To identify SSH keys in the REST API Gerrit uses
+link:rest-api-accounts.html#ssh-key-id[sequence numbers per account].
+This is why the order of the keys in the `authorized_keys` file is
+used to determines the sequence numbers of the keys (the sequence
+numbers start at 1).
+
+To keep the sequence numbers intact when a key is deleted, a
+'# DELETED' line is inserted at the position where the key was deleted.
+
+Invalid keys are marked with the prefix '# INVALID'.
+
+[[external-ids]]
+== External IDs
+
+External IDs are used to link external identities, such as an LDAP
+account or an OAUTH identity, to an account in Gerrit.
+
+External IDs are stored as Git Notes in the `All-Users` repository. The
+name of the notes branch is `refs/meta/external-ids`.
+
+As note key the SHA1 of the external ID key is used. This ensures that
+an external ID is used only once (e.g. an external ID can never be
+assigned to multiple accounts at a point in time).
+
+The note content is a Git config file:
+
+----
+[externalId "username:jdoe"]
+  accountId = 1003407
+  email = jdoe@example.com
+  password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+----
+
+The config file has one `externalId` section. The external ID key which
+consists of scheme and ID in the format '<scheme>:<id>' is used as
+subsection name.
+
+The `accountId` field is mandatory, the `email` and `password` fields
+are optional.
+
+The external IDs are maintained by Gerrit, this means users are not
+allowed to manually edit their external IDs. Only users with the
+link:access-control.html#capability_accessDatabase[Access Database]
+global capability can push updates to the `refs/meta/external-ids`
+branch. However Gerrit rejects pushes if:
+
+* any external ID config file cannot be parsed
+* if a note key does not match the SHA of the external ID key in the
+  note content
+* external IDs for non-existing accounts are contained
+* invalid emails are contained
+* any email is not unique (the same email is assigned to multiple
+  accounts)
+* hashed passwords of external IDs with scheme `username` cannot be
+  decoded
+
+[[starred-changes]]
+== Starred Changes
+
+link:dev-stars.html[Starred changes] allow users to mark changes as
+favorites and receive email notifications for them.
+
+Each starred change is a tuple of an account ID, a change ID and a
+label.
+
+To keep track of a change that is starred by an account, Gerrit creates
+a `refs/starred-changes/YY/XXXX/ZZZZZZZ` ref in the `All-Users`
+repository, where `YY/XXXX` is the sharded numeric change ID and
+`ZZZZZZZ` is the account ID.
+
+A starred-changes ref points to a blob that contains the list of labels
+that the account set on the change. The label list is stored as UTF-8
+text with one label per line.
+
+Since JGit has explicit optimizations for looking up refs by prefix
+when the prefix ends with '/', this ref format is optimized to find
+starred changes by change ID. Finding starred changes by change ID is
+e.g. needed when a change is updated so that all users that have
+the link:dev-stars.html#default-star[default star] on the change can be
+notified by email.
+
+Gerrit also needs an efficient way to find all changes that were
+starred by an account, e.g. to provide results for the
+link:user-search.html#is-starred[is:starred] query operator. With the
+ref format as described above the lookup of starred changes by account
+ID is expensive, as this requires a scan of the full
+`refs/starred-changes/*` namespace. To overcome this the users that
+have starred a change are stored in the change index together with the
+star labels.
+
+[[reviewed-flags]]
+== Reviewed Flags
+
+When reviewing a patch set in the Gerrit UI, the reviewer can mark
+files in the patch set as reviewed. These markers are called ‘Reviewed
+Flags’ and are private to the user. A reviewed flag is a tuple of patch
+set ID, file and account ID.
+
+Each user can have many thousands of reviewed flags and over time the
+number can grow without bounds.
+
+The high amount of reviewed flags makes a storage in Git unsuitable
+because each update requires opening the repository and committing a
+change, which is a high overhead for flipping a bit. Therefore the
+reviewed flags are stored in a database table. By default they are
+stored in a local H2 database, but there is an extension point that
+allows to plug in alternate implementations for storing the reviewed
+flags. To replace the storage for reviewed flags a plugin needs to
+implement the link:dev-plugins.html#account-patch-review-store[
+AccountPatchReviewStore] interface. E.g. to support a multi-master
+setup where reviewed flags should be replicated between the master
+nodes one could implement a store for the reviewed flags that is
+based on MySQL with replication.
+
+[[account-sequence]]
+== Account Sequence
+
+The next available account sequence number is stored as UTF-8 text in a
+blob pointed to by the `refs/sequences/accounts` ref in the `All-Users`
+repository.
+
+Multiple processes share the same sequence by incrementing the counter
+using normal git ref updates. To amortize the cost of these ref
+updates, processes increment the counter by a larger number and hand
+out numbers from that range in memory until they run out. The size of
+the account ID batch that each process retrieves at once is controlled
+by the link:config-gerrit.html#notedb.accounts.sequenceBatchSize[
+notedb.accounts.sequenceBatchSize] parameter in the `gerrit.config`
+file.
+
+[[replication]]
+== Replication
+
+To replicate account data the following branches from the `All-Users`
+repository must be replicated:
+
+* `refs/users/*` (user branches)
+* `refs/meta/external-ids` (external IDs)
+* `refs/starred-changes/*` (star labels)
+* `refs/sequences/accounts` (account sequence numbers, not needed for Gerrit
+  slaves)
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 6e95d7c..3ce5bf8b 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -16,12 +16,15 @@
   packedGitLimit = 200 m
 
 [cache]
-  directory = /var/cache/gerrit2
+  directory = /var/cache/gerrit
 ----
 
 [[accountPatchReviewDb]]
 === Section accountPatchReviewDb
 
+The AccountPatchReviewDb is a database used to store the user file reviewed
+flags. It co-exists with <<database,ReviewDb>> and link:note-db.html[NoteDb].
+
 [[accountPatchReviewDb.url]]accountPatchReviewDb.url::
 +
 The url of accountPatchReviewDb. Supported types are `H2`, `POSTGRESQL`,
@@ -468,8 +471,14 @@
 the "Switch Account" link is displayed next to "Sign Out".
 +
 When `auth.type` does not normally enable this URL administrators may
-set this to `login/` or `$canonicalWebUrl/login`, allowing users to
-begin a new web session.
+set this to `login/`, allowing users to begin a new web session. This value
+is used as an href in PolyGerrit and the GWT UI, so absolute URLs like
+`https://someotherhost/login` work as well.
++
+If a ${path} parameter is included, then PolyGerrit will substitute the
+currently viewed path in the link. Be aware that this path will include
+a leading slash, so a value like this might be appropriate: `/login${path}`.
+Note: in the GWT UI this substitution for ${path} is *always* `/`.
 
 [[auth.cookiePath]]auth.cookiePath::
 +
@@ -622,6 +631,23 @@
 +
 By default, true.
 
+[[auth.autoUpdateAccountActiveStatus]]auth.autoUpdateAccountActiveStatus::
++
+Whether to allow automatic synchronization of an account's inactive flag upon login.
+If set to true, upon login, if the authentication back-end reports the account as active,
+the account's inactive flag in the internal Gerrit database will be updated to be active.
+If the authentication back-end reports the account as inactive, the account's flag will be
+updated to be inactive and the login attempt will be blocked. Users enabling this feature
+should ensure that their authentication back-end is supported. Currently, only
+strict 'LDAP' authentication is supported.
++
+In addition, if this parameter is not set, or false, the corresponding scheduled
+task to deactivate inactive Gerrit accounts will also be disabled. If this
+parameter is set to true, users should also consider configuring the
+link:#accountDeactivation[accountDeactivation] section appropriately.
++
+By default, false.
+
 [[cache]]
 === Section cache
 
@@ -741,26 +767,13 @@
 cache `"accounts"`::
 +
 Cache entries contain important details of an active user, including
-their display name, preferences, known email addresses, and group
-memberships.  Entry information is obtained from the following
-database tables:
-+
-* `accounts`
-+
-* `account_group_members`
-+
-* `account_external_ids`
+their display name, preferences, and known email addresses. Entry
+information is obtained from the `accounts` database table.
 
 +
 If direct updates are made to any of these database tables, this
 cache should be flushed.
 
-cache `"accounts_byemail"`::
-+
-Caches account identities keyed by email address, which is scanned
-from the `account_external_ids` database table.  If updates are
-made to this table, this cache should be flushed.
-
 cache `"adv_bases"`::
 +
 Used only for push over smart HTTP when branch level access controls
@@ -831,16 +844,21 @@
 Caches the basic group information from the `account_groups` table,
 including the group owner, name, and description.
 +
-Gerrit group membership obtained from the `account_group_members`
-table is cached under the `"accounts"` cache, above.  External group
-membership obtained from LDAP is cached under `"ldap_groups"`.
+External group membership obtained from LDAP is cached under
+`"ldap_groups"`.
 
-cache `"groups_byinclude"`::
+cache `"groups_bymember"`::
 +
-Caches group inclusions in other groups.  If direct updates are made
+Caches the groups which contain a specific member (account). If direct
+updates are made to the `account_group_members` table, this cache should
+be flushed.
+
+cache `"groups_bysubgroups"`::
++
+Caches the parent groups of a subgroup.  If direct updates are made
 to the `account_group_includes` table, this cache should be flushed.
 
-cache `"groups_members"`::
+cache `"groups_subgroups"`::
 +
 Caches subgroups.  If direct updates are made to the
 `account_group_includes` table, this cache should be flushed.
@@ -1011,10 +1029,10 @@
 [[capability.administrateServer]]capability.administrateServer::
 +
 Names of groups of users that are allowed to exercise the
-administrateServer capability, in addition to those listed in
+`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
+groups from the `administrateServer` capability, or to ensure that
 specific groups always have administration capabilities.
 +
 ----
@@ -1028,7 +1046,16 @@
 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.
+may use the `administrateServer` capability.
+
+[[capability.makeFirstUserAdmin]]capability.makeFirstUserAdmin::
++
+Whether the first user that logs in to the Gerrit server should
+automatically be added to the administrator group and hence get the
+`administrateServer` capability assigned. This is useful to bootstrap
+the authentication database.
++
+Default is true.
 
 
 [[change]]
@@ -1062,7 +1089,7 @@
 +
 If 0 the update polling is disabled.
 +
-Default is 30 seconds.
+Default is 5 minutes.
 
 [[change.allowBlame]]change.allowBlame::
 +
@@ -1072,10 +1099,14 @@
 
 [[change.allowDrafts]]change.allowDrafts::
 +
-Allow drafts workflow. If set to false, drafts cannot be created,
-deleted or published.
+Legacy support for drafts workflow. If set to true, pushing a new change
+with draft option will create a private change. Pushing with draft option
+to an existing change will create change edit.
 +
-Default is true.
+Enabling this option allows to push to the `refs/drafts/branch`. When
+disabled any push to `refs/drafts/branch` will be rejected.
++
+Default is false.
 
 [[change.cacheAutomerge]]change.cacheAutomerge::
 +
@@ -1174,6 +1205,30 @@
 Default is "Reply and score". In the user interface it becomes "Reply
 and score (Shortcut: a)".
 
+[[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
++
+Maximum allowed size of a robot comment that will be accepted. Robot comments
+which exceed the indicated size will be rejected on addition. The specified
+value is interpreted as the maximum size in bytes of the JSON representation of
+the robot comment. Common unit suffixes of 'k', 'm', or 'g' are supported.
+Zero or negative values allow robot comments of unlimited size.
++
+The default limit is 1024kB.
+
+[[change.strictLabels]]change.strictLabels::
++
+Reject invalid label votes: invalid labels or invalid values. This
+configuration option is provided for backwards compaitbility and may
+be removed in future gerrit versions.
++
+Default is false.
+
+[[change.disablePrivateChanges]]change.disablePrivateChanges::
++
+If set to true, users are not allowed to create private changes.
++
+The default is false.
+
 [[changeCleanup]]
 === Section changeCleanup
 
@@ -1554,15 +1609,20 @@
 [[database]]
 === Section database
 
-The database section configures where Gerrit stores its metadata
-records about user accounts and change reviews.
+The database section configures ReviewDb, where Gerrit stores its metadata
+records about account groups and change reviews. Starting from 2.15, accounts
+are always stored in NoteDb and, optionally, changes too. See the
+link:note-db.html[NoteDb documentation] for more information.
+
+Note that user file reviewed flags are stored in a separate database. See the
+<<accountPatchReviewDb,accountPatchReviewDb>> section for more information.
 
 ----
 [database]
   type = POSTGRESQL
   hostname = localhost
   database = reviewdb
-  username = gerrit2
+  username = gerrit
   password = s3kr3t
 ----
 
@@ -1751,7 +1811,7 @@
 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).
+utils (e.g. MigrateToNoteDb).
 +
 Default is `false`.
 
@@ -2134,6 +2194,19 @@
 Defaults to GWT (if GWT is enabled) or POLYGERRIT (if POLYGERRIT is
 enabled and GWT is disabled)
 
+[[gerrit.serverId]]gerrit.serverId::
++
+Used by NoteDb to, amongst other things, identify author identities from
+per-server specific account IDs.
++
+If this value is not set on startup it is automatically set to a random UUID.
++
+[NOTE]
+If this value doesn't match the serverId used when creating an already existing
+NoteDb, Gerrit will not be able to use that instance of NoteDb. The serverId
+used to create the NoteDb will show in the resulting exception message in case
+the value differs.
+
 [[gitweb]]
 === Section gitweb
 
@@ -2339,6 +2412,14 @@
 +
 Default value is true.
 
+[[http.addUserAsResponseHeader]]http.addUserAsResponseHeader::
++
+If true, the header 'User' will be added to the list of response headers so it
+can be accessed from a reverse proxy for logging purposes.
+
++
+Default value is false.
+
 [[httpd]]
 === Section httpd
 
@@ -2522,6 +2603,12 @@
 Maximum number of threads to permit in the worker thread pool.
 +
 By default 25, suitable for most lower-volume traffic sites.
++
+[NOTE]
+Unless SSH daemon is disabled, see <<sshd.listenAddress, sshd.listenAddress>>,
+the max number of concurrent Git requests over HTTP and SSH together is
+defined by the <<sshd.threads, sshd.threads>> and
+<<sshd.batchThreads, sshd.batchThreads>>.
 
 [[httpd.maxQueued]]httpd.maxQueued::
 +
@@ -2719,6 +2806,19 @@
 +
 Defaults to 1024.
 
+[[index.reindexAfterRefUpdate]]index.reindexAfterRefUpdate::
++
+Whether to reindex all affected open changes after a ref is updated. This
+includes reindexing all open changes to recompute the "mergeable" bit every time
+the destination branch moves, as well as reindexing changes to take into account
+new project configuration (e.g. label definitions).
++
+Leaving this enabled may result in fresher results, but may cause performance
+problems if there are lots of open changes on a project whose branches advance
+frequently.
++
+Defaults to true.
+
 [[index.autoReindexIfStale]]index.autoReindexIfStale::
 +
 Whether to automatically check if a document became stale in the index
@@ -2842,7 +2942,7 @@
 for production use. For compatibility information, please refer to the
 link:https://www.gerritcodereview.com/elasticsearch.html[project homepage].
 
-When using Elasticsearch versions 2.4 and 5.6, the open and closed changes are
+When using Elasticsearch version 5.6, the open and closed changes are
 indexed in a single index, separated into types `open_changes` and `closed_changes`
 respectively. When using version 6.2 or later, the open and closed changes are
 merged into the default `_doc` type. The latter is also used for the accounts and
@@ -2871,13 +2971,21 @@
 server. To configure multiple servers the `gerrit.config` file must be edited
 manually.
 
-[[elasticsearch.maxRetryTimeout]]elasticsearch.maxRetryTimeout::
+[[elasticsearch.numberOfShards]]elasticsearch.numberOfShards::
 +
-Sets the maximum timeout to honor in case of multiple retries of the same request.
+Sets the number of shards to use per index. Refer to the
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-concepts.html#getting-started-shards-and-replicas[
+Elasticsearch documentation] for details.
 +
-The value is in the usual time-unit format like `1 m`, `5 m`.
+Defaults to 5 for Elasticsearch versions 5 and 6, and to 1 starting with Elasticsearch 7.
+
+[[elasticsearch.numberOfReplicas]]elasticsearch.numberOfReplicas::
 +
-Defaults to `30000 ms`.
+Sets the number of replicas to use per index. Refer to the
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-concepts.html#getting-started-shards-and-replicas[
+Elasticsearch documentation] for details.
++
+Defaults to 1.
 
 ==== Elasticsearch Security
 
@@ -2886,11 +2994,12 @@
 
 For further information about Elasticsearch security, please refer to the documentation:
 
-* link:https://www.elastic.co/guide/en/elasticsearch/plugins/2.4/security.html[Elasticsearch 2.4]
 * link:https://www.elastic.co/guide/en/x-pack/5.6/security-getting-started.html[Elasticsearch 5.6]
 * link:https://www.elastic.co/guide/en/x-pack/6.2/security-getting-started.html[Elasticsearch 6.2]
 * link:https://www.elastic.co/guide/en/elastic-stack-overview/6.3/security-getting-started.html[Elasticsearch 6.3]
 * link:https://www.elastic.co/guide/en/elastic-stack-overview/6.4/security-getting-started.html[Elasticsearch 6.4]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.5/security-getting-started.html[Elasticsearch 6.5]
+* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.6/security-getting-started.html[Elasticsearch 6.6]
 
 [[elasticsearch.username]]elasticsearch.username::
 +
@@ -3317,6 +3426,19 @@
 +
 Defaults to true.
 
+[[log.rotate]]log.rotate::
++
+If set to true, log files are rotated daily at midnight (GMT).
++
+Defaults to true.
+
+[[log.compress]]log.compress::
++
+If set to true, log files are compressed at server startup and then daily at 11pm
+(in the server's local time zone).
++
+Defaults to true.
+
 [[mimetype]]
 === Section mimetype
 
@@ -3349,8 +3471,41 @@
 === Section noteDb
 
 NoteDb is the next generation of Gerrit storage backend, currently powering
-`googlesource.com`. It is not (yet) recommended for general use, but if you want
-to learn more, see the link:dev-note-db.html[developer documentation].
+`googlesource.com`. For more information, including how to migrate your data,
+see the link:note-db.html[documentation].
+
+[[notedb.accounts.sequenceBatchSize]]notedb.accounts.sequenceBatchSize::
++
+The next available account sequence number is stored as UTF-8 text in a
+blob pointed to by the `refs/sequences/accounts` ref in the `All-Users`
+repository. Multiple processes share the same sequence by incrementing
+the counter using normal git ref updates. To amortize the cost of these
+ref updates, processes increment the counter by a larger number and
+hand out numbers from that range in memory until they run out. This
+configuration parameter controls the size of the account ID batch that
+each process retrieves at once.
++
+By default, 1.
+
+[[noteDb.retryMaxWait]]noteDb.retryMaxWait::
++
+Maximum time to wait between attempts to retry update operations when one
+attempt fails due to contention (aka lock failure) on the underlying ref
+storage. Operations are retried with exponential backoff, plus some random
+jitter, until the interval reaches this limit. After that, retries continue to
+occur after a fixed timeout (plus jitter), up to
+link:#noteDb.retryTimeout[`noteDb.retryTimeout`].
++
+Defaults to 5 seconds; unit suffixes are supported, and assumes milliseconds if
+not specified.
+
+[[noteDb.retryTimeout]]noteDb.retryTimeout::
++
+Total timeout for retrying update operations when one attempt fails due to
+contention (aka lock failure) on the underlying ref storage.
++
+Defaults to 20 seconds; unit suffixes are supported, and assumes milliseconds if
+not specified.
 
 [[oauth]]
 === Section oauth
@@ -3486,10 +3641,9 @@
 [[receive.checkMagicRefs]]receive.checkMagicRefs::
 +
 If true, Gerrit will verify the destination repository has
-no references under the magic 'refs/drafts', 'refs/for', or
-'refs/publish' branch namespaces. Names under these locations
-confuse clients when trying to upload code reviews so Gerrit
-requires them to be empty.
+no references under the magic 'refs/for' branch namespace. Names under
+these locations confuse clients when trying to upload code reviews so
+Gerrit requires them to be empty.
 +
 If false Gerrit skips the sanity check and assumes administrators
 have ensured the repository does not contain any magic references.
@@ -3536,6 +3690,15 @@
 +
 Default is zero, no limit.
 
+[[receive.maxBatchCommits]]receive.maxBatchCommits::
++
+The maximum number of commits that Gerrit allows to be pushed in a batch
+directly to a branch when link:user-upload.html#bypass_review[bypassing review].
+This limit can be bypassed if a user link:user-upload.html#skip_validation[skips
+validation].
++
+Default is 10000.
+
 [[receive.maxObjectSizeLimit]]receive.maxObjectSizeLimit::
 +
 Maximum allowed Git object size that 'receive-pack' will accept.
@@ -3659,7 +3822,10 @@
 +
 For more details see link:project-configuration.html#submit_type[Submit Types].
 +
-By default, `MERGE_IF_NECESSARY`.
+This submit type is only applied at project creation time if a submit type is
+omitted from the link:rest-api-projects.html#project-input[ProjectInput]. If the
+submit type is unset in the project config at runtime, it defaults to
+link:project-configuration.html#merge_if_necessary[`MERGE_IF_NECESSARY`].
 
 [[repository.name.ownerGroup]]repository.<name>.ownerGroup::
 +
@@ -3995,15 +4161,25 @@
 Defaults to an empty string which adds <<sendemail.from,sendemail.from>> as
 Reply-To if inbound email is enabled and the review's author otherwise.
 
+[[sendemail.allowTLD]]sendemail.allowTLD::
++
+List of custom TLDs to allow sending emails to in addition to those specified
+in the link:http://data.iana.org/TLD/[IANA list].
++
+Defaults to an empty list, meaning no additional TLDs are allowed.
+
 [[site]]
 === Section site
 
 [[site.allowOriginRegex]]site.allowOriginRegex::
 +
 List of regular expressions matching origins that should be permitted
-to use the Gerrit REST API to read content. These should be trusted
-applications as the sites may be able to use the user's credentials.
-Only applies to GET and HEAD requests.
+to use the full Gerrit REST API.  These should be trusted applications,
+as the sites may be able to use the user's credentials. Applies to
+all requests, including state changing methods (PUT, DELETE, POST).
++
+Expressions should not require trailing slash. For example a valid
+pattern might be `https://build-status[.]example[.]com`.
 +
 By default, unset, denying all cross-origin requests.
 
@@ -4110,7 +4286,12 @@
 If additional requests are received while all threads are busy they
 are queued and serviced in a first-come-first-served order.
 +
-By default, 2x the number of CPUs available to the JVM.
+By default, 2x the number of CPUs available to the JVM (but at least 4
+threads).
++
+[NOTE]
+When SSH daemon is enabled then this setting also defines the max number of
+concurrent Git requests for interactive users over SSH and HTTP together.
 
 [[sshd.batchThreads]]sshd.batchThreads::
 +
@@ -4129,6 +4310,10 @@
 value of sshd.threads is increased to accommodate the requested value.
 +
 By default is 1 on single core node, 2 otherwise.
++
+[NOTE]
+When SSH daemon is enabled then this setting also defines the max number of
+concurrent Git requests for batch users over SSH and HTTP together.
 
 [[sshd.streamThreads]]sshd.streamThreads::
 +
@@ -4562,6 +4747,44 @@
 If no groups are added, any user will be allowed to execute
 'upload-pack' on the server.
 
+[[accountDeactivation]]
+=== Section accountDeactivation
+
+Configures the parameters for the scheduled task to sweep and deactivate Gerrit
+accounts according to their status reported by the auth backend. Currently only
+supported for LDAP backends.
+
+[[accountDeactivation.startTime]]accountDeactivation.startTime::
++
+Start time to define the first execution of account deactivations.
+If the configured `'accountDeactivation.interval'` is shorter than `'accountDeactivation.startTime - now'`
+the start time will be preponed by the maximum integral multiple of
+`'accountDeactivation.interval'` so that the start time is still in the future.
++
+----
+<day of week> <hours>:<minutes>
+or
+<hours>:<minutes>
+
+<day of week> : Mon, Tue, Wed, Thu, Fri, Sat, Sun
+<hours>       : 00-23
+<minutes>     : 0-59
+----
+
+[[accountDeactivation.interval]]accountDeactivation.interval::
++
+Interval for periodic repetition of triggering account deactivation sweeps.
+The interval must be larger than zero. The following suffixes are supported
+to define the time unit for the interval:
++
+* `s, sec, second, seconds`
+* `m, min, minute, minutes`
+* `h, hr, hour, hours`
+* `d, day, days`
+* `w, week, weeks` (`1 week` is treated as `7 days`)
+* `mon, month, months` (`1 month` is treated as `30 days`)
+* `y, year, years` (`1 year` is treated as `365 days`)
+
 [[urlAlias]]
 === Section urlAlias
 
@@ -4626,6 +4849,21 @@
 +
 By default this is true.
 
+[[submodule.maxCombinedCommitMessageSize]]submodule.maxCombinedCommitMessageSize::
++
+This allows to limit the length of the commit message for a submodule.
++
+By default this is 262144 (256 KiB).
++
+Common unit suffixes of k, m, or g are supported.
+
+[[submodule.maxCommitMessages]]submodule.maxCommitMessages::
++
+This allows to limit the number of commit messages that should be combined when creating
+a commit message for a submodule.
++
+By default this is 1000.
+
 [[user]]
 === Section user
 
@@ -4703,6 +4941,10 @@
 by Gerrit.  If you modify any columns in this table, Gerrit needs
 to be restarted before it will use the new values.
 
+== Configuring the Polygerrit UI
+
+Please see link:dev-polygerrit.html[UI] on configuring the Polygerrit UI.
+
 === Configurable Parameters
 
 site_path::
diff --git a/Documentation/config-hooks.txt b/Documentation/config-hooks.txt
index a71595f..835ec11 100644
--- a/Documentation/config-hooks.txt
+++ b/Documentation/config-hooks.txt
@@ -2,7 +2,7 @@
 
 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[
+the link:https://gerrit-review.googlesource.com/admin/repos/plugins/hooks[
 hooks plugin].
 
 GERRIT
diff --git a/Documentation/config-login-register.txt b/Documentation/config-login-register.txt
index 1639c8a..3dcef0a 100644
--- a/Documentation/config-login-register.txt
+++ b/Documentation/config-login-register.txt
@@ -1,6 +1,6 @@
 [[usersetup]]
 == Initial Login
-It's time to exit the gerrit2 account as you now have Gerrit running on your
+It's time to exit the gerrit account as you now have Gerrit running on your
 host and setup your first workspace.
 
 Start a shell with the credentials of the account you will perform
@@ -57,9 +57,9 @@
 find the url in the settings file.
 
 ----
-  gerrit2@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config gerrit.canonicalWebUrl
+  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config gerrit.canonicalWebUrl
   http://localhost:8080/
-  gerrit2@host:~$
+  gerrit@host:~$
 ----
 
 Register a new account in Gerrit through the web interface with the
@@ -70,9 +70,9 @@
 proxy settings in the configuration file.
 
 ----
-  gerrit2@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxy http://proxy:8080
-  gerrit2@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyUsername username
-  gerrit2@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyPassword password
+  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxy http://proxy:8080
+  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyUsername username
+  gerrit@host:~$ git config -f ~/gerrit_testsite/etc/gerrit.config --add http.proxyPassword password
 ----
 
 Refer to the Gerrit configuration guide for more detailed information about
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 9eb31bf..91c7abb 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -65,6 +65,12 @@
 will be appended to emails related to a user submitting comments on changes.
 See `ChangeSubject.soy`, Comment and ChangeFooter.
 
+=== DeleteKey.soy and DeleteKeyHtml.soy
+
+DeleteKey templates will determine the contents of the email related to SSH or GPG keys
+being deleted from a user account. This notification is not sent when the key is
+administratively deleted from another user account.
+
 === DeleteVote.soy and DeleteVoteHtml.soy
 
 The DeleteVote templates will determine the contents of the email related to
@@ -82,6 +88,11 @@
 The Footer templates will determine the contents of the footer text appended to
 the end of all outgoing emails after the ChangeFooter and CommentFooter.
 
+=== HttpPasswordUpdate.soy and HttpPasswordUpdateHtml.soy
+
+HttpPasswordUpdate templates will determine the contents of the email related to adding,
+changing or deleting the HTTP password on a user account.
+
 === Merged.soy and MergedHtml.soy
 
 The Merged templates will determine the contents of the email related to a
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index a652136..0a7f5a9 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -46,30 +46,20 @@
 message body, and reports warnings or errors to the git client if the
 lengths are exceeded.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/commit-message-length-validator[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/commit-message-length-validator[
 Project] |
 link:https://gerrit.googlesource.com/plugins/commit-message-length-validator/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
 link:https://gerrit.googlesource.com/plugins/commit-message-length-validator/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
-[[cookbook-plugin]]
-=== cookbook-plugin
-
-Sample plugin to demonstrate features of Gerrit's plugin API.
-
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/cookbook-plugin[
-Project] |
-link:https://gerrit.googlesource.com/plugins/cookbook-plugin/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
-
 [[download-commands]]
 === download-commands
 
 This plugin defines commands for downloading changes in different
 download schemes (for downloading via different network protocols).
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/download-commands[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/download-commands[
 Project] |
 link:https://gerrit.googlesource.com/plugins/download-commands/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -81,7 +71,7 @@
 
 This plugin runs server-side hooks on events.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/hooks[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/hooks[
 Project] |
 link:https://gerrit.googlesource.com/plugins/hooks/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -96,7 +86,7 @@
 be configured to provide mirroring of changes, for warm-standby
 backups, or a load-balanced public mirror farm.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/replication[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/replication[
 Project] |
 link:https://gerrit.googlesource.com/plugins/replication/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -109,21 +99,11 @@
 Stores review information for Gerrit changes in the `refs/notes/review`
 branch.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewnotes[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewnotes[
 Project] |
 link:https://gerrit.googlesource.com/plugins/reviewnotes/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 
-[[review-strategy]]
-=== review-strategy
-
-This plugin allows users to configure different review strategies.
-
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/review-strategy[
-Project] |
-link:https://gerrit.googlesource.com/plugins/review-strategy/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
-
 [[singleusergroup]]
 === singleusergroup
 
@@ -147,7 +127,7 @@
 
 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[
+link:https://gerrit-review.googlesource.com/admin/repos/?filter=plugins%252F[
 gerrit-review].
 
 [[admin-console]]
@@ -158,7 +138,7 @@
 information. Also providing access control information by project or
 project/account.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/admin-console[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/admin-console[
 Project] |
 link:https://gerrit.googlesource.com/plugins/admin-console/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -172,7 +152,7 @@
 archived and processed with popular BigData transformation tools such
 Apache Spark or published and visualized in dashboards.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/analytics[Project] |
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/analytics[Project] |
 link:https://gerrit.googlesource.com/plugins/analytics/+doc/master/README.md[Documentation]
 
 [[avatars-external]]
@@ -181,7 +161,7 @@
 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/repos/plugins/avatars-external[
 Project] |
 link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -193,7 +173,7 @@
 
 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/repos/plugins/avatars-gravatar[
 Project]
 
 [[branch-network]]
@@ -204,7 +184,7 @@
 "project link" in a gitweb configuration or by other Gerrit GWT UI
 plugins to be plugged elsewhere in Gerrit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/branch-network[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/branch-network[
 Project] |
 link:https://gerrit.googlesource.com/plugins/branch-network/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -216,7 +196,7 @@
 
 This plugin allows to display a static info message on the change screen.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/changemessage[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/changemessage[
 Project] |
 link:https://gerrit.googlesource.com/plugins/changemessage/+doc/master/src/main/resources/Documentation/about.md[
 Plugin Documentation] |
@@ -228,7 +208,7 @@
 
 Provides the ability to delete a project.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/delete-project[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/delete-project[
 Project] |
 link:https://gerrit.googlesource.com/plugins/delete-project/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -242,7 +222,7 @@
 the change ref into the clipboard. The change ref is needed for
 downloading a Gerrit change from within EGit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/egit[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/egit[
 Project] |
 link:https://gerrit.googlesource.com/plugins/egit/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -252,29 +232,19 @@
 
 This plugin allows users to see emoticons in comments as images.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/emoticons[
+link:https://gerrit-review.googlesource.com/admin/repos/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
-
-Provides an ssh command to force a change or patch set to draft status.
-This is useful for administrators to be able to easily completely
-delete a change or patch set from the server.
-
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/force-draft[
-Project]
-
 [[gitblit]]
 === gitblit
 
 GitBlit code-viewer plugin with SSO and Security Access Control.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/gitblit[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/gitblit[
 Project]
 
 [[github]]
@@ -282,7 +252,7 @@
 
 Plugin to integrate with GitHub: replication, pull-request to Change-Sets
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/github[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/github[
 Project]
 
 [[gitiles]]
@@ -290,7 +260,7 @@
 
 Plugin running Gitiles alongside a Gerrit server.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/gitiles[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/gitiles[
 Project]
 
 [[healthcheck]]
@@ -318,7 +288,7 @@
 
 The imagare plugin allows Gerrit users to upload and share images.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/imagare[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/imagare[
 Project] |
 link:https://gerrit.googlesource.com/plugins/imagare/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -346,7 +316,7 @@
 server, and in combination with the link:#delete-project[delete-project]
 plugin it can be used to rename a project.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/importer[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/importer[
 Project] |
 link:https://gerrit.googlesource.com/plugins/importer/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -362,7 +332,7 @@
 the `its-base` project. `its-base` is not a plugin, but just a
 framework for the ITS plugins which is packaged within each ITS plugin.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-base[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-base[
 its-base Project] |
 link:https://gerrit.googlesource.com/plugins/its-base/+doc/master/src/main/resources/Documentation/about.md[
 its-base Documentation] |
@@ -374,7 +344,7 @@
 
 Plugin to integrate with Bugzilla.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-bugzilla[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-bugzilla[
 Project] |
 link:https://gerrit.googlesource.com/plugins/its-bugzilla/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -384,17 +354,27 @@
 
 Plugin to integrate with Jira.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-jira[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-jira[
 Project] |
 link:https://gerrit.googlesource.com/plugins/its-jira/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[its-phabricator]]
+==== its-phabricator
+
+Plugin to integrate with Phabricator.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-phabricator[
+Project] |
+link:https://gerrit.googlesource.com/plugins/its-phabricator/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[its-rtc]]
 ==== its-rtc
 
 Plugin to integrate with IBM Rational Team Concert (RTC).
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-rtc[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-rtc[
 Project] |
 link:https://gerrit.googlesource.com/plugins/its-rtc/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
@@ -404,7 +384,7 @@
 
 Plugin to integrate with Storyboard task tracking system.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-storyboard[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-storyboard[
 Project] |
 link:https://gerrit.googlesource.com/plugins/its-storyboard/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -417,7 +397,7 @@
 This plugin integrates JavaMelody in Gerrit in order to retrieve live
 instrumentation data from Gerrit.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/javamelody[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/javamelody[
 Project] |
 link:https://gerrit.googlesource.com/plugins/javamelody/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -432,7 +412,7 @@
 (similar to how labels/approvals were rendered on the old change
 screen).
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/labelui[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/labelui[
 Project] |
 link:https://gerrit.googlesource.com/plugins/labelui/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -443,7 +423,7 @@
 The menuextender plugin allows Gerrit administrators to configure
 additional menu entries from the WebUI.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/menuextender[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/menuextender[
 Project] |
 link:https://gerrit.googlesource.com/plugins/menuextender/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -455,7 +435,7 @@
 
 This plugin reports Gerrit metrics to Elasticsearch.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-elasticsearch[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-elasticsearch[
 Project].
 
 [[metrics-reporter-graphite]]
@@ -463,7 +443,7 @@
 
 This plugin reports Gerrit metrics to Graphite.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-graphite[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-graphite[
 Project].
 
 [[metrics-reporter-jmx]]
@@ -471,7 +451,7 @@
 
 This plugin reports Gerrit metrics to JMX.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/metrics-reporter-jmx[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/metrics-reporter-jmx[
 Project].
 
 [[motd]]
@@ -483,7 +463,7 @@
 the user (usually prefixed by “remote: ”), but will be silently
 discarded otherwise.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/motd[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/motd[
 Project] |
 link:https://gerrit.googlesource.com/plugins/motd/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -506,7 +486,7 @@
 This plugin provides a Prolog predicate `add_owner_approval/3` that
 appends `label('Owner-Approval', need(_))` to a provided list.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/owners[Project] |
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/owners[Project] |
 link:https://gerrit.googlesource.com/plugins/owners/+doc/master/README.md[Documentation]
 
 [[project-download-commands]]
@@ -518,7 +498,7 @@
 are inherited by the child projects. Child projects can overwrite the
 inherited download command or remove it by assigning no value to it.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/project-download-commands[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/project-download-commands[
 Project] |
 link:https://gerrit.googlesource.com/plugins/project-download-commands/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -534,20 +514,30 @@
 that a project or group can consume. To do this a Gerrit administrator
 can use this plugin to define quotas on project namespaces.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/quota[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/quota[
 Project] |
 link:https://gerrit.googlesource.com/plugins/quota/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
 link:https://gerrit.googlesource.com/plugins/quota/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[rabbitmq]]
+=== rabbitmq
+
+A plugin that publishes Gerrit events to a
+link:https://www.rabbitmq.com/[RabbitMQ] exchange.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/rabbitmq[Project]
+link:https://gerrit.googlesource.com/plugins/rabbitmq/+/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[readonly]]
 === readonly
 
 A plugin that makes the Gerrit server read-only by rejecting git pushes,
 blocking HTTP PUT/POST/DELETE requests, and disabling SSH commands.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/readonly[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/readonly[
 Project] |
 link:https://gerrit.googlesource.com/plugins/readonly/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -562,7 +552,7 @@
 Backups of deleted or non-fast-forward updated refs are created under the
 `refs/backups/` namespace.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/ref-protection[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/ref-protection[
 Project] |
 link:https://gerrit.googlesource.com/plugins/ref-protection/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -572,19 +562,29 @@
 
 A plugin that provides project reparenting as a self-service for project owners.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reparent[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/reparent[
 Project] |
 link:https://gerrit.googlesource.com/plugins/reparent/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
 link:https://gerrit.googlesource.com/plugins/reparent/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[review-strategy]]
+=== review-strategy
+
+This plugin allows users to configure different review strategies.
+
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/review-strategy[
+Project] |
+link:https://gerrit.googlesource.com/plugins/review-strategy/+doc/master/src/main/resources/Documentation/about.md[
+Documentation]
+
 [[reviewers]]
 === reviewers
 
 A plugin that allows adding default reviewers to a change.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewers[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers[
 Project] |
 link:https://gerrit.googlesource.com/plugins/reviewers/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -600,7 +600,7 @@
 users should be familiar with the code and can mostly review the
 change.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewers-by-blame[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers-by-blame[
 Project] |
 link:https://gerrit.googlesource.com/plugins/reviewers-by-blame/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -612,7 +612,7 @@
 
 This plugin provides a Groovy runtime environment for Gerrit plugins in Groovy.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/groovy-provider[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/scripting/groovy-provider[
 Project] |
 link:https://gerrit.googlesource.com/plugins/scripting/groovy-provider/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -622,7 +622,7 @@
 
 This plugin provides a Scala runtime environment for Gerrit plugins in Scala.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/scripting/scala-provider[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/scripting/scala-provider[
 Project] |
 link:https://gerrit.googlesource.com/plugins/scripting/scala-provider/+doc/master/src/main/resources/Documentation/about.md[
 Documentation]
@@ -636,7 +636,7 @@
 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-review.googlesource.com/admin/repos/plugins/scripts[Project]
 link:https://gerrit.googlesource.com/plugins/scripts/+doc/master/README.md[Documentation]
 
 [[server-config]]
@@ -648,7 +648,7 @@
 where Gerrit's config files are stored is difficult or impossible to
 get.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/server-config[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/server-config[
 Project]
 
 [[serviceuser]]
@@ -661,7 +661,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/serviceuser[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/serviceuser[
 Project] |
 link:https://gerrit.googlesource.com/plugins/serviceuser/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -677,7 +677,7 @@
 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/uploadvalidator[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/uploadvalidator[
 Project] |
 link:https://gerrit.googlesource.com/plugins/uploadvalidator/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -691,7 +691,7 @@
 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[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/verify-status[
 Project] |
 link:https://gerrit.googlesource.com/plugins/verify-status/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
@@ -706,37 +706,19 @@
 among multiple Gerrit servers, making it useful for multi-master
 Gerrit installations.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/websession-flatfile[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/websession-flatfile[
 Project] |
 link:https://gerrit.googlesource.com/plugins/websession-flatfile/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
 link:https://gerrit.googlesource.com/plugins/websession-flatfile/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
-[[wip]]
-=== wip
-
-This plugin adds a new button that allows a change owner to set a
-change to Work In Progress, and a button to change from WIP back to a
-"Ready For Review" state.
-
-Any change in the WIP state will not show up in anyone's Review
-Requests. Pushing a new patchset will reset the change to Review In
-Progress.
-
-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] |
-link:https://gerrit.googlesource.com/plugins/wip/+doc/master/src/main/resources/Documentation/config.md[
-Configuration]
-
 [[x-docs]]
 === x-docs
 
 This plugin serves project documentation as HTML pages.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/x-docs[
+link:https://gerrit-review.googlesource.com/admin/repos/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 ed0b151..653f976b 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -206,6 +206,37 @@
 Default is `INHERIT`, which means that this property is inherited from
 the parent project.
 
+[[change-section]]
+=== Change section
+
+The change section includes configuration for project-specific change settings:
+
+[[change.privateByDefault]]change.privateByDefault::
++
+Controls whether all new changes in the project are set as private by default.
++
+Note that a new change will be public if the `is_private` field in
+link:rest-api-changes.html#change-input[ChangeInput] is set to `false` explicitly
+when calling the link:rest-api-changes.html#create-change[CreateChange] REST API
+or the `remove-private` link:user-upload.html#private[PushOption] is used during
+the Git push.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project.
+
+[[change.workInProgressByDefault]]change.workInProgressByDefault::
++
+Controls whether all new changes in the project are set as WIP by default.
++
+Note that a new change will be ready if the `workInProgress` field in
+link:rest-api-changes.html#change-input[ChangeInput] is set to `false` explicitly
+when calling the link:rest-api-changes.html#create-change[CreateChange] REST API
+or the `ready` link:user-upload.html#wip[PushOption] is used during
+the Git push.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project.
+
 [[submit-section]]
 === Submit section
 
@@ -217,7 +248,13 @@
 
 - 'action': defines the link:project-configuration.html#submit_type[submit type].  Valid
 values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
-'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
+'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
+
+- 'matchAuthorToCommitterDate': Defines whether to the author date will be changed to match the
+submitter date upon submit, so that git log shows when the change was submitted instead of when the
+author last committed. Valid values are 'true', 'false', or 'INHERIT'. The default is 'INHERIT'.
+This option only takes effect in submit strategies which already modify the commit, i.e.
+Cherry Pick, Rebase Always, and (perhaps) Rebase If Necessary.
 
 Merge strategy
 
@@ -298,6 +335,22 @@
 check. If the `branchOrder` section is not defined then the mergeability of a
 change into other branches will not be done.
 
+[[reviewer-section]]
+=== reviewer section
+
+Defines config options to adjust a project's reviewer workflow such as enabling
+reviewers and CCs by email.
+
+[[reviewer.enableByEmail]]reviewer.enableByEmail::
++
+A boolean indicating if reviewers and CCs that do not currently have a Gerrit
+account can be added to a change by providing their email address.
+
+This setting only takes affect for changes that are readable by anonymous users.
+
+Default is `INHERIT`, which means that this property is inherited from
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
 
 [[file-groups]]
 == The file +groups+
diff --git a/Documentation/config-sso.txt b/Documentation/config-sso.txt
index 7814061..6f3a32d 100644
--- a/Documentation/config-sso.txt
+++ b/Documentation/config-sso.txt
@@ -50,8 +50,8 @@
 
 === Database Schema
 
-User identities obtained from OpenID providers are stored into the
-`account_external_ids` table.
+User identities obtained from OpenID providers are stored as
+link:config-accounts.html#external-ids[external IDs].
 
 === Multiple Identities
 
@@ -134,11 +134,10 @@
 
 === Database Schema
 
-User identities are stored in the `account_external_ids` table.
-The user string obtained from the authorization header has the prefix
-"gerrit:" and is stored in the `external_id` field.  For example,
-if a username was "foo" then the external_id field would be populated
-with "gerrit:foo".
+User identities are stored as
+link:config-accounts.html#external-ids[external IDs] with "gerrit" as
+scheme. The user string obtained from the authorization header is
+stored as ID of the external ID.
 
 
 == Computer Associates Siteminder
@@ -192,11 +191,10 @@
 
 === Database Schema
 
-User identities are stored in the `account_external_ids` table.
-The user string obtained from Siteminder (e.g. the value in the
-"SM_USER" HTTP header) has the prefix "gerrit:" and is stored in the
-`external_id` field.  For example, if a Siteminder username was "foo"
-then the external_id field would be populated with "gerrit:foo".
+User identities are stored as
+link:config-accounts.html#external-ids[external IDs] with "gerrit" as
+scheme. The user string obtained from Siteminder (e.g. the value in the
+"SM_USER" HTTP header) is stored as ID in the external ID.
 
 GERRIT
 ------
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
index 2dbec2d..2153751 100644
--- a/Documentation/database-setup.txt
+++ b/Documentation/database-setup.txt
@@ -16,7 +16,8 @@
 with the database while Gerrit is offline, it's not easy to backup the data,
 and it's not possible to set up H2 in a load balanced/hotswap configuration.
 
-If this option interests you, you might want to consider link:install-quick.html[the quick guide].
+If this option interests you, you might want to consider
+link:linux-quickstart.html[the quick guide].
 
 [[createdb_derby]]
 === Apache Derby
@@ -45,8 +46,8 @@
 full rights on the newly created database:
 
 ----
-  $ createuser --username=postgres -RDIElPS gerrit2
-  $ createdb --username=postgres -E UTF-8 -O gerrit2 reviewdb
+  $ createuser --username=postgres -RDIElPS gerrit
+  $ createdb --username=postgres -E UTF-8 -O gerrit reviewdb
 ----
 
 Visit PostgreSQL's link:http://www.postgresql.org/docs/9.1/interactive/index.html[documentation] for further information regarding
@@ -67,9 +68,9 @@
 ----
   mysql
 
-  CREATE USER 'gerrit2'@'localhost' IDENTIFIED BY 'secret';
+  CREATE USER 'gerrit'@'localhost' IDENTIFIED BY 'secret';
   CREATE DATABASE reviewdb DEFAULT CHARACTER SET 'utf8';
-  GRANT ALL ON reviewdb.* TO 'gerrit2'@'localhost';
+  GRANT ALL ON reviewdb.* TO 'gerrit'@'localhost';
   FLUSH PRIVILEGES;
 ----
 
@@ -97,8 +98,8 @@
 password, and grant the user full rights on the newly created database:
 
 ----
-  SQL> create user gerrit2 identified by secret_password default tablespace users;
-  SQL> grant connect, resources to gerrit2;
+  SQL> create user gerrit identified by secret_password default tablespace users;
+  SQL> grant connect, resources to gerrit;
 ----
 
 JDBC driver ojdbc6.jar must be obtained from your Oracle distribution. Gerrit
@@ -119,7 +120,7 @@
         type = oracle
         instance = xe
         hostname = localhost
-        username = gerrit2
+        username = gerrit
         port = 1521
 ----
 
@@ -138,7 +139,7 @@
 MaxDB installation to reduce administrative overhead.
 
 In the MaxDB studio or using the SQLCLI command line interface create a user
-'gerrit2' with the user class 'RESOURCE' and a password <secret password>. This
+'gerrit' with the user class 'RESOURCE' and a password <secret password>. This
 will also create an associated schema on the database.
 
 To run Gerrit on MaxDB, you need to obtain the MaxDB JDBC driver. It can be
@@ -159,7 +160,7 @@
         type = maxdb
         database = reviewdb
         hostname = localhost
-        username = gerrit2
+        username = gerrit
 
 ----
 
@@ -186,7 +187,7 @@
 ----
   db2 => create database gerrit
   db2 => connect to gerrit
-  db2 => grant connect,accessctrl,dataaccess,dbadm,secadm on database to gerrit2;
+  db2 => grant connect,accessctrl,dataaccess,dbadm,secadm on database to gerrit;
 ----
 
 JDBC driver db2jcc4.jar and db2jcc_license_cu.jar must be obtained
@@ -208,7 +209,7 @@
         type = db2
         database = gerrit
         hostname = localhost
-        username = gerrit2
+        username = gerrit
         port = 50001
 ----
 
@@ -239,7 +240,7 @@
 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
+the host 'hana.host' and listening on port '4242' where a schema/user GERRIT2
 was created:
 
 In $site_path/etc/gerrit.config:
@@ -247,8 +248,23 @@
 ----
 [database]
         type = hana
-        instance = 00
         hostname = hana.host
+        port = 4242
+        username = GERRIT2
+
+----
+
+In order to configure a specific database in a multi-database environment (MDC)
+the database name has to be specified additionally:
+
+In $site_path/etc/gerrit.config:
+
+----
+[database]
+        type = hana
+        hostname = hana.host
+        database = tdb1
+        port = 4242
         username = GERRIT2
 
 ----
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 3cbb609..e29519d 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -37,7 +37,7 @@
 === Gerrit Release WAR File
 
 To build the Gerrit web application that includes the GWT UI, the
-PolyGerrit UI and documentation:
+PolyGerrit UI, core plugins and documentation:
 
 ----
   bazel build release
@@ -231,7 +231,7 @@
 Primary storage NoteDb and ReviewDb disabled:
 
 ----
-  bazel test --test_env=GERRIT_NOTEDB=DISABLE_CHANGE_REVIEW_DB //...
+  bazel test --test_env=GERRIT_NOTEDB=ON //...
 ----
 
 To run only tests that do not use SSH:
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt
index 072c22c..3e80857 100644
--- a/Documentation/dev-build-plugins.txt
+++ b/Documentation/dev-build-plugins.txt
@@ -122,16 +122,25 @@
 ----
 
 If the plugin(s) being bundled in the release have external dependencies, include them
-in `plugins/external_plugin_deps`. You should alias `external_plugin_deps()` so it
-can be imported for multiple plugins. For example:
+in `plugins/external_plugin_deps`. Create symbolic link from plugin's own
+`external_plugin_deps()` file in plugins directory and prefix the file with
+plugin name, e.g.:
 
 ----
-load(":my-plugin/external_plugin_deps.bzl", my_plugin="external_plugin_deps")
-load(":my-other-plugin/external_plugin_deps.bzl", my_other_plugin="external_plugin_deps")
+  $ cd plugins
+  $ ln -s oauth/external_plugin_deps.bzl oauth_external_plugin_deps.bzl
+  $ ln -s uploadvalidator/external_plugin_deps.bzl uploadvalidator_external_plugin_deps.bzl
+----
+
+Now the plugin specific dependency files can be imported:
+
+----
+load(":oauth_external_plugin_deps.bzl", oauth_deps="external_plugin_deps")
+load(":uploadvalidator_external_plugin_deps.bzl", uploadvalidator_deps="external_plugin_deps")
 
 def external_plugin_deps():
-  my_plugin()
-  my_other_plugin()
+  oauth_deps()
+  uploadvalidator_deps()
 ----
 
 [NOTE]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 41b718e..1bb6cb5 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -17,10 +17,10 @@
 tab on the settings page
 * Click 'New Contributor Agreement' and follow the instructions
 
-For reference, the actual agreements are linked below
+For reference, the actual agreements are linked below:
 
-* link:https://cla.developers.google.com/about/android-individual[Individual Agreement]
-* link:https://source.android.com/source/cla-corporate.pdf[Corporate Agreement]
+* link:https://cla.developers.google.com/about/google-individual[Individual Agreement]
+* link:https://cla.developers.google.com/about/google-corporate[Corporate Agreement]
 
 == Code Review
 As Gerrit is a code review tool, naturally contributions will
@@ -144,9 +144,25 @@
 link:https://gerrit-review.googlesource.com/#/settings/http-password[HTTP
 Password tab of the user settings page].
 
+Alternately, you may use the
+link:https://pypi.org/project/git-review/[git-review] tool to submit changes
+to Gerrit. If you do, it will set up the Change-Id hook and `gerrit` remote
+for you. You will still need to do the HTTP access step.
+
 [[style]]
 === Style
 
+This project has a policy of Eclipse's warning free code. Eclipse
+configuration is added to git and we expect the changes to be
+warnings free.
+
+We do not ask you to use Eclipse for editing, obviously.  We do ask you
+to provide Eclipse's warning free patches only. If for some reasons, you
+are not able to set up Eclipse and verify, that your patch hasn't
+introduced any new Eclipse warnings, mention this in a comment to your
+change, so that reviewers will do it for you. Yes, the way to go is to
+extend gerrit CI to take care of this, but it's not yet implemented.
+
 Gerrit generally follows the
 link:https://google.github.io/styleguide/javaguide.html[Google Java Style
 Guide].
@@ -342,7 +358,20 @@
 We have created a
 link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject]
 category in the issue tracker and try to assign easy hack projects to it. If in
-doubt, do not hesitate to ask on the developer mailing list.
+doubt, do not hesitate to ask on the developer
+link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list].
+
+=== Upgrading Libraries
+
+Gerrit's library dependencies should only be upgraded if the new version contains
+something we need in Gerrit. This includes new features, API changes as well as bug
+or security fixes.
+An exception to this rule is that right after a new Gerrit release was branched
+off, all libraries should be upgraded to the latest version to prevent Gerrit
+from falling behind. Doing those upgrades should conclude at the latest two
+months after the branch was cut. This should happen on the master branch to ensure
+that they are vetted long enough before they go into a release and we can be sure
+that the update doesn't introduce a regression.
 
 GERRIT
 ------
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 810a0ba..397cd1b 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -10,8 +10,8 @@
 [[setup]]
 == Project Setup
 
-In your Eclipse installation's `eclipse.ini` file, add the following line in
-the `vmargs` section:
+In your Eclipse installation's link:https://wiki.eclipse.org/Eclipse.ini[`eclipse.ini`] file,
+add the following line in the `vmargs` section:
 
 ----
   -DmaxCompiledUnitsAtOnce=10000
@@ -30,7 +30,8 @@
   AutoAnnotation_Commands_named cannot be resolved to a type
 ----
 
-In Eclipse, choose 'Import existing project' and select the `gerrit` project
+First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
+Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
 
 Expand the `gerrit` project, right-click on the `eclipse-out` folder, select
@@ -41,6 +42,13 @@
 Filters on a folder, they will be overwritten the next time you run
 `tools/eclipse/project.py`.
 
+=== Eclipse project with custom plugins ===
+
+To add custom plugins to the eclipse project add them to `tools/bzl/plugins.bzl`
+the same way you would when
+link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
+and run `tools/eclipse/project.py`.
+
 
 [[Formatting]]
 == Code Formatter Settings
@@ -126,17 +134,17 @@
 
 `codeserver` needs two additional inputs to expose the plugin module in the SDM
 debug session: the module name and the source folder location. For example the
-module name and source folder of `cookbook-plugin` should be added in the local
+module name and source folder of any GWT plugin should be added in the local
 copy of the `gerrit_gwt_debug` configuration:
 
 ----
-  com.googlesource.gerrit.plugins.cookbook.HelloForm \
-  -src ${resource_loc:/gerrit}/plugins/cookbook-plugin/src/main/java \
+  com.googlesource.gerrit.plugins.myplugin.HelloForm \
+  -src ${resource_loc:/gerrit}/plugins/myplugin/src/main/java \
   -- --console-log [...]
 ----
 
 After doing that, both the Gerrit core and plugin GWT modules can be activated
-during SDM (debug session)[http://imgur.com/HFXZ5No].
+during SDM (debug session).
 
 GERRIT
 ------
diff --git a/Documentation/dev-note-db.txt b/Documentation/dev-note-db.txt
deleted file mode 100644
index dd3b316..0000000
--- a/Documentation/dev-note-db.txt
+++ /dev/null
@@ -1,137 +0,0 @@
-= Gerrit Code Review - NoteDb Backend
-
-NoteDb is the next generation of Gerrit storage backend, which replaces the
-traditional SQL backend for change and account metadata with storing data in the
-same repository as code changes.
-
-.Advantages
-- *Simplicity*: All data is stored in one location in the site directory, rather
-  than being split between the site directory and a possibly external database
-  server.
-- *Consistency*: Replication and backups can use a snapshot of the Git
-  repository refs, which will include both the branch and patch set refs, and
-  the change metadata that points to them.
-- *Auditability*: Rather than storing mutable rows in a database, modifications
-  to changes are stored as a sequence of Git commits, automatically preserving
-  history of the metadata. +
-  There are no strict guarantees, and meta refs may be rewritten, but the
-  default assumption is that all operations are logged.
-- *Extensibility*: Plugin developers can add new fields to metadata without the
-  core database schema having to know about them.
-- *New features*: Enables simple federation between Gerrit servers, as well as
-  offline code review and interoperation with other tools.
-
-== Current Status
-
-- Storing change metadata is fully implemented in master, and is live on the
-  servers behind `googlesource.com`. In other words, if you use
-  link:https://gerrit-review.googlesource.com/[gerrit-review], you're already
-  using NoteDb. +
-- Storing some account data, e.g. user preferences, is implemented in releases
-  back to 2.13.
-- Storing the rest of account data is a work in progress.
-- Storing group data is a work in progress.
-
-To match the current configuration of `googlesource.com`, paste the following
-config snippet in your `gerrit.config`:
-
-----
-[noteDb "changes"]
-  write = true
-  read = true
-  primaryStorage = NOTE_DB
-  disableReviewDb = true
-----
-
-
-For an example NoteDb change, poke around at this one:
-----
-  git fetch https://gerrit.googlesource.com/gerrit refs/changes/70/98070/meta \
-      && git log -p FETCH_HEAD
-----
-
-== Configuration
-
-Account and group data is migrated to NoteDb automatically using the normal
-schema upgrade process during updates. The remainder of this section details the
-configuration options that control migration of the change data, which is mostly
-but not fully implemented.
-
-Change migration state is configured in `gerrit.config` with options like
-`noteDb.changes.*`. These options are undocumented outside of this file, and the
-general approach has been to add one new option for each phase of the migration.
-Assume that each config option in the following list requires all of the
-previous options, unless otherwise noted.
-
-- `noteDb.changes.write=true`: During a ReviewDb write, the state of the change
-  in NoteDb is written to the `note_db_state` field in the `Change` entity.
-  After the ReviewDb write, this state is written into NoteDb, resulting in
-  effectively double the time for write operations. NoteDb write errors are
-  dropped on the floor, and no attempt is made to read from ReviewDb or correct
-  errors (without additional configuration, below). +
-  This state allows for a rolling update in a multi-master setting, where some
-  servers can start reading from NoteDb, but older servers are still reading
-  only from ReviewDb.
-- `noteDb.changes.read=true`: Change data is written
-  to and read from NoteDb, but ReviewDb is still the source of truth. During
-  reads, first read the change from ReviewDb, and compare its `note_db_state`
-  with what is in NoteDb. If it doesn't match, immediately "auto-rebuild" the
-  change, copying data from ReviewDb to NoteDb and returning the result.
-- `noteDb.changes.primaryStorage=NOTE_DB`: New changes are written only to
-  NoteDb, but changes whose primary storage is ReviewDb are still supported.
-  Continues to read from ReviewDb first as in the previous stage, but if the
-  change is not in ReviewDb, falls back to reading from NoteDb. +
-  Migration of existing changes is described in the link:#migration[Migration]
-  section below. +
-  Due to an implementation detail, writes to Changes or related tables still
-  result in write calls to the database layer, but they are inside a transaction
-  that is always rolled back.
-- `noteDb.changes.disableReviewDb=true`: All access to Changes or related tables
-  is disabled; reads return no results, and writes are no-ops. Assumes the state
-  of all changes in NoteDb is accurate, and so is only safe once all changes are
-  NoteDb primary. Otherwise, reading changes only from NoteDb might result in
-  inaccurate results, and writing to NoteDb would compound the problem. +
-  Thus it is up to an admin of a previously-ReviewDb site to ensure
-  MigratePrimaryStorage has been run for all changes. Note that the current
-  implementation of the `rebuild-note-db` program does not do this. +
-  In this phase, it would be possible to delete the Changes tables out from
-  under a running server with no effect.
-
-[[migration]]
-== Migration
-
-Once configuration options are set, migration to NoteDb is primarily
-accomplished by running the `rebuild-note-db` program. Currently, this program
-bulk copies ReviewDb data into NoteDb, but leaves primary storage of these
-changes in ReviewDb, so the site is runnable with
-`noteDb.changes.{write,read}=true`, but ReviewDb is still required.
-
-Eventually, `rebuild-note-db` will set primary storage to NoteDb for all
-changes by default, so a site will be able to stop using ReviewDb for changes
-immediately after a successful run.
-
-There is code in `PrimaryStorageMigrator.java` to migrate individual changes
-from NoteDb primary to ReviewDb primary. This code is not intended to be used
-except in the event of a critical bug in NoteDb primary changes in production.
-It will likely never be used by `rebuild-note-db`, and in fact it's not
-recommended to run `rebuild-note-db` until the code is stable enough that the
-reverse migration won't be necessary.
-
-=== Zero-Downtime Multi-Master Migration
-
-Single-master Gerrit sites can use `rebuild-note-db` on an offline site to
-rebuild NoteDb, but this doesn't work in a zero-downtime environment like
-googlesource.com.
-
-Here, the migration process looks like:
-
-- Turn on `noteDb.changes.write=true` to start writing to NoteDb.
-- Run a parallel link:https://research.google.com/pubs/pub35650.html[FlumeJava]
-  pipeline to write NoteDb data for all changes, and update all `note_db_state`
-  fields. (Sorry, this implementation is entirely closed-source.)
-- Turn on `noteDb.changes.read=true` to start reading from NoteDb.
-- Turn on `noteDb.changes.primaryStorage=NOTE_DB` to start writing new changes
-  to NoteDb only.
-- Run a Flume to migrate all existing changes to NoteDb primary. (Also
-  closed-source, but basically just a wrapper around `PrimaryStorageMigrator`.)
-- Turn off access to ReviewDb changes tables.
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 3092909..f4ccb9d 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -3,6 +3,9 @@
 The Gerrit server functionality can be extended by installing plugins.
 This page describes how plugins for Gerrit can be developed.
 
+For PolyGerrit-specific plugin development, consult with
+link:pg-plugin-dev.html[PolyGerrit Plugin Development] guide.
+
 Depending on how tightly the extension code is coupled with the Gerrit
 server code, there is a distinction between `plugins` and `extensions`.
 
@@ -13,6 +16,11 @@
 may require source code changes to compile against a different
 server version.
 
+Plugins may require a specific major.minor.patch server version
+and may need rebuild and revalidation across different
+patch levels. A different patch level may only add new
+API interfaces and never change or extend existing ones.
+
 [[extension]]
 An `extension` in Gerrit runs inside of the same JVM as Gerrit
 in the same way as a plugin, but has limited visibility to the
@@ -25,12 +33,9 @@
 [[getting-started]]
 == Getting started
 
-To get started with the development of a plugin clone the sample
-plugin:
-
-----
-$ git clone https://gerrit.googlesource.com/plugins/cookbook-plugin
-----
+To get started with the development of a plugin, take a look at
+the samples in the
+link:https://gerrit.googlesource.com/plugins/examples[examples plugin project].
 
 This is a project that demonstrates the various features of the
 plugin API. It can be taken as an example to develop an own plugin.
@@ -477,10 +482,15 @@
 [[receive-pack]]
 == Receive Pack Initializers
 
-Plugins may provide ReceivePack initializers which will be invoked
-by Gerrit just before a ReceivePack instance will be used. Usually,
-plugins will make use of the setXXX methods on the ReceivePack to
-set additional properties on it.
+Plugins may provide ReceivePackInitializer instances, which will be
+invoked by Gerrit just before a ReceivePack instance will be used.
+Usually, plugins will make use of the setXXX methods on the ReceivePack
+to set additional properties on it.
+
+The interactions with the core Gerrit ReceivePack initialization and
+between ReceivePackInitializers can be complex. Please read the
+ReceivePack Javadoc and Gerrit AsyncReceiveCommits implementation
+carefully.
 
 [[post-receive-hook]]
 == Post Receive-Pack Hooks
@@ -490,6 +500,19 @@
 for those plugins which would like to monitor changes in Git
 repositories.
 
+[[upload-pack]]
+== Upload Pack Initializers
+
+Plugins may provide UploadPackInitializer instances, which will be
+invoked by Gerrit just before a UploadPack instance will be used.
+Usually, plugins will make use of the setXXX methods on the UploadPack
+to set additional properties on it.
+
+The interactions with the core Gerrit UploadPack initialization and
+between UploadPackInitializers can be complex. Please read the
+UploadPack Javadoc and Gerrit Upload/UploadFactory implementations
+carefully.
+
 [[pre-upload-hook]]
 == Pre Upload-Pack Hooks
 
@@ -712,6 +735,99 @@
     }
 ====
 
+[[command_options]]
+=== Command Options ===
+
+Plugins can provide additional options for each of the gerrit ssh and the
+REST API commands by implementing the DynamicBean interface and registering
+it to a command class name in the plugin module's `configure()` method. The
+plugin's name will be prepended to the name of each @Option annotation found
+on the DynamicBean object provided by the plugin. The example below shows a
+plugin that adds an option to log a value from the gerrit 'ban-commits'
+ssh command.
+
+[source, java]
+----
+public class SshModule extends AbstractModule {
+  private static final Logger log = LoggerFactory.getLogger(SshModule.class);
+
+  @Override
+  protected void configure() {
+    bind(DynamicOptions.DynamicBean.class)
+        .annotatedWith(Exports.named(
+        com.google.gerrit.sshd.commands.BanCommitCommand.class))
+        .to(BanOptions.class);
+  }
+
+  public static class BanOptions implements DynamicOptions.DynamicBean {
+    @Option(name = "--log", aliases = { "-l" }, usage = "Say Hello in the Log")
+    private void parse(String arg) {
+      log.error("Say Hello in the Log " + arg);
+    }
+  }
+----
+
+[[query_attributes]]
+=== Query Attributes ===
+
+Plugins can provide additional attributes to be returned in Gerrit queries by
+implementing the ChangeAttributeFactory interface and registering it to the
+ChangeQueryProcessor.ChangeAttributeFactory class in the plugin module's
+'configure()' method. The new attribute(s) will be output under a "plugin"
+attribute in the change query output.
+
+The example below shows a plugin that adds two attributes ('exampleName' and
+'changeValue'), to the change query output.
+
+[source, java]
+----
+public class Module extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ChangeAttributeFactory.class)
+        .annotatedWith(Exports.named("example"))
+        .to(AttributeFactory.class);
+  }
+}
+
+public class AttributeFactory implements ChangeAttributeFactory {
+
+  public class PluginAttribute extends PluginDefinedInfo {
+    public String exampleName;
+    public String changeValue;
+
+    public PluginAttribute(ChangeData c) {
+      this.exampleName = "Attribute Example";
+      this.changeValue = Integer.toString(c.getId().get());
+    }
+  }
+
+  @Override
+  public PluginDefinedInfo create(ChangeData c, ChangeQueryProcessor qp, String plugin) {
+    return new PluginAttribute(c);
+  }
+}
+----
+
+Example
+----
+
+ssh -p 29418 localhost gerrit query "change:1" --format json
+
+Output:
+
+{
+   "url" : "http://localhost:8080/1",
+   "plugins" : [
+      {
+         "name" : "myplugin-name",
+         "exampleName" : "Attribute Example",
+         "changeValue" : "1"
+      }
+   ],
+    ...
+}
+----
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
@@ -1220,6 +1336,7 @@
   @Override
   public void onPluginLoad() {
     Plugin.get().panel(GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
+        "my_panel_name",
         new Panel.EntryPoint() {
           @Override
           public void onLoad(Panel panel) {
@@ -1231,6 +1348,23 @@
 }
 ----
 
+Change Screen panel ordering may be specified in the
+project config. Values may be either "plugin name" or
+"plugin name"."panel name".
+Panels not specified in the config will be added
+to the end in load order. Panels specified in the config that
+are not found will be ignored.
+
+Example config:
+----
+[extension-panels "CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK"]
+        panel = helloworld.change_id
+        panel = myotherplugin
+        panel = myplugin.my_panel_name
+----
+
+
+
 [[actions]]
 === Actions
 
@@ -2208,6 +2342,10 @@
 
 TagWebLinks will appear in the tag list in the last column.
 
+If a `get*WebLink` implementation returns `null`, the link will be omitted. This
+allows the plugin to selectively "enable" itself on a per-project/branch/file
+basis.
+
 [[lfs-extension]]
 == LFS Storage Plugins
 
@@ -2446,14 +2584,26 @@
 Compiled plugins and extensions can be deployed to a running Gerrit
 server using the link:cmd-plugin-install.html[plugin install] command.
 
-Web UI plugins distributed as  single `.js` file can be deployed
-without the overhead of JAR packaging, for more information refer to
-link:cmd-plugin-install.html[plugin install] command.
+Web UI plugins distributed as a single `.js` file (or `.html` file for
+Polygerrit) can be deployed without the overhead of JAR packaging. For
+more information refer to link:cmd-plugin-install.html[plugin install]
+command.
 
-Plugins can also be copied directly into the server's
-directory at `$site_path/plugins/$name.(jar|js)`.  The name of
-the JAR file, minus the `.jar` or `.js` extension, will be used as the
-plugin name. Unless disabled, servers periodically scan this
+Plugins can also be copied directly into the server's directory at
+`$site_path/plugins/$name.(jar|js|html)`. For Web UI plugins, the name
+of the file, minus the `.js` or `.html` extension, will be used as the
+plugin name. For JAR plugins, the value of the `Gerrit-PluginName`
+manifest attribute will be used, if provided, otherwise the name of
+the file, minus the `.jar` extension, will be used.
+
+For Web UI plugins, the plugin version is derived from the filename.
+If the filename contains one or more hyphens, the version is taken
+from the portion following the last hyphen. For example if the plugin
+filename is `my-plugin-1.0.js` the version will be `1.0`. For JAR
+plugins, the version is taken from the `Version` attribute in the
+manifest.
+
+Unless disabled, servers periodically scan the `$site_path/plugins`
 directory for updated plugins. The time can be adjusted by
 link:config-gerrit.html#plugins.checkFrequency[plugins.checkFrequency].
 
@@ -2560,8 +2710,8 @@
 }
 ----
 
-[[ssh-command-interception]]
-== SSH Command Interception
+[[ssh-command-creation-interception]]
+== SSH Command Creation Interception
 
 Gerrit provides an extension point that allows a plugin to intercept
 creation of SSH commands and override the functionality with its own
@@ -2579,6 +2729,40 @@
 }
 ----
 
+[[ssh-command-execution-interception]]
+== SSH Command Execution Interception
+Gerrit provides an extension point that enables plugins to check and
+prevent an SSH command from being run.
+
+[source, java]
+----
+import com.google.gerrit.sshd.SshExecuteCommandInterceptor;
+
+@Singleton
+public class SshExecuteCommandInterceptorImpl implements SshExecuteCommandInterceptor {
+  private final Provider<SshSession> sessionProvider;
+
+  @Inject
+  SshExecuteCommandInterceptorImpl(Provider<SshSession> sessionProvider) {
+    this.sessionProvider = sessionProvider;
+  }
+
+  @Override
+  public boolean accept(String command, List<String> arguments) {
+    if (command.startsWith("gerrit") && !"10.1.2.3".equals(sessionProvider.get().getRemoteAddressAsString())) {
+      return false;
+    }
+    return true;
+  }
+}
+----
+
+And then declare it in your SSH module:
+[source, java]
+----
+  DynamicSet.bind(binder(), SshExecuteCommandInterceptor.class).to(SshExecuteCommandInterceptorImpl.class);
+----
+
 
 == SEE ALSO
 
diff --git a/Documentation/dev-polygerrit.txt b/Documentation/dev-polygerrit.txt
new file mode 100644
index 0000000..7898ae9
--- /dev/null
+++ b/Documentation/dev-polygerrit.txt
@@ -0,0 +1,37 @@
+= PolyGerrit - GUI
+
+[IMPORTANT]
+PolyGerrit is still a beta feature...
+
+Missing features in PolyGerrit:
+
+- Inline Edit
+
+- And many more features missing.
+
+== Configuring
+
+By default both GWT and PolyGerrit UI are available to users.
+
+To disable GWT but not PolyGerrit:
+----
+[gerrit]
+        enableGwtUi = false
+        enablePolyGerrit = true
+----
+
+To enable GWT but not PolyGerrit:
+----
+[gerrit]
+        enableGwtUi = true
+        enablePolyGerrit = false
+----
+
+To switch to the PolyGerrit UI you have to add `?polygerrit=1` in the URL.
+
+for example https://gerrit.example.org/?polygerrit=1
+
+To disable PolyGerrit UI, change 1 to 0, which will take you back to GWT UI.
+
+
+More information can be found in the link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/[README]
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index d43c863..5f95cb3 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -38,15 +38,21 @@
 
 * Generate and publish a PGP key
 +
+A PGP key is needed to be able to sign the release artifacts before
+the upload to Maven Central, and to sign the release announcement email.
++
 Generate and publish a PGP key as described in
 link:http://central.sonatype.org/pages/working-with-pgp-signatures.html[
-Working with PGP Signatures].
+Working with PGP Signatures]. In addition to the keyserver mentioned
+there it is recommended to also publish the key to the
+link:https://keyserver.ubuntu.com/[Ubuntu key server].
 +
 Please be aware that after publishing your public key it may take a
 while until it is visible to the Sonatype server.
 +
-The PGP key is needed to be able to sign the artifacts before the
-upload to Maven Central.
+Add an entry for the public key in the
+link:https://gerrit.googlesource.com/homepage/+/md-pages/releases/public-keys.md[key list]
+on the homepage.
 +
 The PGP passphrase can be put in `~/.m2/settings.xml`:
 +
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 2a857b2..52237194 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -16,8 +16,8 @@
 == Gerrit Release Type
 
 Here are some guidelines on release approaches depending on the
-type of release you want to make (`stable-fix`, `stable`, `RC0`,
-`RC1`...).
+type of release you want to make (`stable-fix`, `stable`, `rc0`,
+`rc1`...).
 
 [[stable]]
 === Stable
@@ -27,19 +27,19 @@
 
 * Propose the release with any plans/objectives to the mailing list
 
-* Create a Gerrit `RC0`
+* Create a Gerrit `rc0`
 
-* If needed create a Gerrit `RC1`
+* If needed create a Gerrit `rc1`
 
 [NOTE]
 You may let in a few features to this release
 
-* If needed create a Gerrit `RC2`
+* 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`)
+* Finally create the `stable` release (no `rc`)
 
 
 === Stable-Fix
@@ -75,7 +75,6 @@
 
 To create a Gerrit release the following steps have to be done:
 
-. link:#subproject[Release Subprojects]
 . link:#build-gerrit[Build the Gerrit Release]
 . link:#publish-gerrit[Publish the Gerrit Release]
 .. link:#publish-to-maven-central[Publish the Gerrit artifacts to Maven Central]
@@ -90,34 +89,10 @@
 . link:#merge-stable[Merge `stable` into `master`]
 
 
-[[subproject]]
-=== Release Subprojects
-
-The subprojects to be released are:
-
-* `gwtjsonrpc`
-* `gwtorm`
-* `prolog-cafe`
-
-For each subproject do:
-
-* Check the dependency to the Subproject in the Gerrit parent `pom.xml`:
-+
-If a `SNAPSHOT` version of the subproject is referenced the subproject
-needs to be released so that Gerrit can reference a released version of
-the subproject.
-
-* link:dev-release-subproject.html#make-snapshot[Make a snapshot and test it]
-* link:dev-release-subproject.html#prepare-release[Prepare the Release]
-* link:dev-release-subproject.html#publish-release[Publish the Release]
-
-* Update the `artifact`, `sha1`, and `src_sha1` values in the `maven_jar`
-for the Subproject in `WORKSPACE` to the released version.
-
 [[update-versions]]
 === Update Versions and Create Release Tag
 
-Before doing the release build, the `GERRIT_VERSION` in the `VERSION`
+Before doing the release build, the `GERRIT_VERSION` in the `version.bzl`
 file must be updated, e.g. change it from `2.5-SNAPSHOT` to `2.5`.
 
 In addition the version must be updated in a number of pom.xml files.
@@ -129,16 +104,16 @@
   ./tools/version.py 2.5
 ----
 
-Commit the changes and create the release tag on the new commit:
+Commit the changes and create a signed release tag on the new commit:
 
 ----
-  git tag -a v2.5
+  git tag -s -m "v2.5" v2.5
 ----
 
 Tag the plugins:
 
 ----
-  git submodule foreach git tag -a v2.5
+  git submodule foreach git tag -s -m "v2.5" v2.5
 ----
 
 [[build-gerrit]]
@@ -283,9 +258,12 @@
 ==== Push the Stable Branch
 
 * Create the stable branch `stable-2.5` in the `gerrit` project via the
-link:https://gerrit-review.googlesource.com/#/admin/projects/gerrit,branches[
+link:https://gerrit-review.googlesource.com/admin/repos/gerrit,branches[
 Gerrit Web UI] or by push.
 
+* Create a change updating the `defaultbranch` field in the `.gitreview`
+to match the branch name created.
+
 * Push the commits done on `stable-2.5` to `refs/for/stable-2.5` and
 get them merged
 
@@ -330,7 +308,7 @@
 [[update-links]]
 ==== Update homepage links
 
-Upload a change on the link:https://gerrit-review.googlesource.com/#/admin/projects/homepage[
+Upload a change on the link:https://gerrit-review.googlesource.com/admin/repos/homepage[
 homepage project] to change the version numbers to the new version.
 
 [[update-issues]]
@@ -351,27 +329,17 @@
 [[announce]]
 ==== Announce on Mailing List
 
-* Send an email to the mailing list to announce the release, consider
-including some or all of the following in the email:
-** A link to the release and the release notes
-** A link to the docs
-** Describe the type of release (stable, bug fix, RC)
-** 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 bazel-bin/release.war` or an equivalent
-command.
+Send an email to the mailing list to announce the release. The content of the
+announcement email is generated with the `release-announcement.py` which
+automatically includes all the necessary links, hash values, and wraps the
+text in a PGP signature.
 
-* Update the new discussion group announcement to be sticky
-** Go to: http://groups.google.com/group/repo-discuss/topics
-** Click on the announcement thread
-** Near the top right, click on actions
-** Under actions, click the "Display this top first" checkbox
+For details refer to the documentation in the script's header, and/or the
+help text:
 
-* Update the previous discussion group announcement to no longer be sticky
-** See above (unclick checkbox)
-
+----
+ ./tools/release-announcement.py --help
+----
 
 [[increase-version]]
 === Increase Gerrit Version for Current Development
@@ -383,7 +351,7 @@
 Use the `version` tool to set the version in the `version.bzl` file:
 
 ----
- ./tools/version.py 2.11-SNAPSHOT
+ ./tools/version.py 2.6-SNAPSHOT
 ----
 
 Verify that the changes made by the tool are sane, then commit them, push
@@ -403,6 +371,13 @@
   git merge stable
 ----
 
+[[update-api-version-in-bazlets-repository]]
+
+Bazlets is used by gerrit plugins to simplify build process. To allow the
+new released version to be used by gerrit plugins,
+link:https://gerrit.googlesource.com/bazlets/+/master/gerrit_api.bzl#8[gerrit_api.bzl]
+must reference the new version. Upload a change to bazlets repository with
+api version upgrade.
 
 GERRIT
 ------
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
index 553ac5b..a83ad44 100644
--- a/Documentation/dev-stars.txt
+++ b/Documentation/dev-stars.txt
@@ -61,6 +61,25 @@
 
 The ignore star is represented by the special star label 'ignore'.
 
+[[reviewed-star]]
+== Reviewed Star
+
+If the "reviewed/<patchset_id>"-star is set by a user, and <patchset_id>
+matches the current patch set, the change is always reported as "reviewed"
+in the ChangeInfo.
+
+This allows users to "de-highlight" changes in a dashboard until a new
+patchset has been uploaded.
+
+[[unreviewed-star]]
+== Unreviewed Star
+
+If the "unreviewed/<patchset_id>"-star is set by a user, and <patchset_id>
+matches the current patch set, the change is always reported as "unreviewed"
+in the ChangeInfo.
+
+This allows users to "highlight" changes in a dashboard.
+
 [[query-stars]]
 == Query Stars
 
diff --git a/Documentation/error-messages.txt b/Documentation/error-messages.txt
index 2632254..ca8dc75 100644
--- a/Documentation/error-messages.txt
+++ b/Documentation/error-messages.txt
@@ -32,6 +32,7 @@
 * link:error-prohibited-by-gerrit.html[prohibited by Gerrit]
 * link:error-project-not-found.html[Project not found: ...]
 * link:error-same-change-id-in-multiple-changes.html[same Change-Id in multiple changes]
+* link:error-too-many-commits.html[too many commits]
 * 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-missing-changeid.txt b/Documentation/error-missing-changeid.txt
index 9cddd85..08f2c09 100644
--- a/Documentation/error-missing-changeid.txt
+++ b/Documentation/error-missing-changeid.txt
@@ -1,4 +1,4 @@
-= missing Change-Id in commit message footer
+= commit xxxxxxx: missing Change-Id in message footer
 
 With this error message Gerrit rejects to push a commit to a project
 which is configured to always require a Change-Id in the commit
diff --git a/Documentation/error-missing-subject.txt b/Documentation/error-missing-subject.txt
index 3703ade..6ef37a4 100644
--- a/Documentation/error-missing-subject.txt
+++ b/Documentation/error-missing-subject.txt
@@ -1,4 +1,4 @@
-= missing subject; Change-Id must be in commit message footer
+= commit xxxxxxx: missing subject; Change-Id must be in message footer
 
 With this error message Gerrit rejects to push a commit to a project
 which is configured to always require a Change-Id in the commit
diff --git a/Documentation/error-multiple-changeid-lines.txt b/Documentation/error-multiple-changeid-lines.txt
index 0729547..31567f4 100644
--- a/Documentation/error-multiple-changeid-lines.txt
+++ b/Documentation/error-multiple-changeid-lines.txt
@@ -1,4 +1,4 @@
-= multiple Change-Id lines in commit message footer
+= commit xxxxxxx: multiple Change-Id lines in message footer
 
 With this error message Gerrit rejects to push a commit if the commit
 message footer of the pushed commit contains several Change-Id lines.
diff --git a/Documentation/error-too-many-commits.txt b/Documentation/error-too-many-commits.txt
new file mode 100644
index 0000000..3e16220
--- /dev/null
+++ b/Documentation/error-too-many-commits.txt
@@ -0,0 +1,20 @@
+= too many commits
+
+This error occurs when a push directly to a branch
+link:user-upload.html#bypass_review[bypassing review] contains more commits than
+the server is able to validate in a single batch.
+
+The recommended way to avoid this message is to use the
+link:user-upload.html#skip_validation[`skip-validation` push option]. Depending
+on the number of commits, it may also be feasible to split the push into smaller
+batches.
+
+The actual limit is controlled by a
+link:config-gerrit.html#receive.maxBatchCommits[server config option].
+
+GERRIT
+------
+Part of link:error-messages.html[Gerrit Error Messages]
+
+SEARCHBOX
+---------
diff --git a/Documentation/images/intro-quick-new-review.jpg b/Documentation/images/intro-quick-new-review.jpg
deleted file mode 100644
index 99e6c55..0000000
--- a/Documentation/images/intro-quick-new-review.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-new-review.png b/Documentation/images/intro-quick-new-review.png
new file mode 100644
index 0000000..36d93e9
--- /dev/null
+++ b/Documentation/images/intro-quick-new-review.png
Binary files differ
diff --git a/Documentation/images/intro-quick-review-2-patches.jpg b/Documentation/images/intro-quick-review-2-patches.jpg
deleted file mode 100644
index 29c99cc..0000000
--- a/Documentation/images/intro-quick-review-2-patches.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-review-2-patches.png b/Documentation/images/intro-quick-review-2-patches.png
new file mode 100644
index 0000000..d7e9129
--- /dev/null
+++ b/Documentation/images/intro-quick-review-2-patches.png
Binary files differ
diff --git a/Documentation/images/intro-quick-review-line-comment.jpg b/Documentation/images/intro-quick-review-line-comment.jpg
deleted file mode 100644
index eeb144a..0000000
--- a/Documentation/images/intro-quick-review-line-comment.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-review-line-comment.png b/Documentation/images/intro-quick-review-line-comment.png
new file mode 100644
index 0000000..7964365
--- /dev/null
+++ b/Documentation/images/intro-quick-review-line-comment.png
Binary files differ
diff --git a/Documentation/images/intro-quick-reviewing-the-change.jpg b/Documentation/images/intro-quick-reviewing-the-change.jpg
deleted file mode 100644
index bfded9e..0000000
--- a/Documentation/images/intro-quick-reviewing-the-change.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-reviewing-the-change.png b/Documentation/images/intro-quick-reviewing-the-change.png
new file mode 100644
index 0000000..bdce6bd
--- /dev/null
+++ b/Documentation/images/intro-quick-reviewing-the-change.png
Binary files differ
diff --git a/Documentation/images/intro-quick-verifying.jpg b/Documentation/images/intro-quick-verifying.jpg
deleted file mode 100644
index 7679c0a..0000000
--- a/Documentation/images/intro-quick-verifying.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-verifying.png b/Documentation/images/intro-quick-verifying.png
new file mode 100644
index 0000000..e343cc9
--- /dev/null
+++ b/Documentation/images/intro-quick-verifying.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 511f19a..24c538f 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -1,11 +1,20 @@
 = Gerrit Code Review for Git
 
-== Tutorial
-. Getting started
-.. link:intro-quick.html[A Quick Introduction to Gerrit]
-.. link:intro-user.html[User Guide]
-.. link:intro-project-owner.html[Project Owner Guide]
-.. link:http://source.android.com/source/life-of-a-patch[Default Android Workflow] (external)
+== Quickstarts
+
+. link:linux-quickstart.html[Quickstart for Installing Gerrit on Linux]
+
+== About Gerrit
+. link:intro-quick.html[Product Overview]
+. link:intro-how-gerrit-works.html[How Gerrit Works]
+. link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
+
+== Guides
+. link:intro-user.html[User Guide]
+. link:intro-project-owner.html[Project Owner Guide]
+. link:https://source.android.com/source/developing[Default Android Workflow] (external)
+
+== Tutorials
 . Web
 .. link:user-review-ui.html[Reviewing Changes]
 .. link:user-search.html[Searching Changes]
@@ -57,6 +66,8 @@
 . 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]
+. link:note-db.html[NoteDb]
+. link:config-accounts.html[Accounts]
 
 == Developer
 . Getting Started
@@ -73,13 +84,19 @@
 .. link:dev-stars.html[Starring Changes]
 . link:dev-design.html[System Design]
 . link:i18n-readme.html[i18n Support]
-. link:dev-note-db.html[NoteDb]
 
 == Maintainer
 . 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]
 
+== Concepts
+. link:config-labels.html[Review Labels]
+. link:access-control.html[Access Controls]
+. link:concept-changes.html[Changes]
+. link:concept-refs-for-namespace.html[The refs/for Namespace]
+. link:concept-patch-sets.html[Patch Sets]
+
 == Resources
 * link:licenses.html[Licenses and Notices]
 * link:https://www.gerritcodereview.com/[Homepage]
diff --git a/Documentation/install-quick.txt b/Documentation/install-quick.txt
deleted file mode 100644
index 3ab2d4b..0000000
--- a/Documentation/install-quick.txt
+++ /dev/null
@@ -1,227 +0,0 @@
-= Gerrit Code Review - Quick get started guide
-
-****
-This guide was made with the impatient in mind, ready to try out Gerrit on their
-own server but not prepared to make the full installation procedure yet.
-
-Explanation is sparse and you should not use a server installed this way in a
-live setup, this is made with proof of concept activities in mind.
-
-It is presumed you install it on a Unix based server such as any of the Linux
-flavors or BSD.
-
-It's also presumed that you have access to an OpenID enabled email address.
-Examples of OpenID enable email providers are Gmail, Yahoo! Mail and Hotmail.
-It's also possible to register a custom email address with OpenID, but that is
-outside the scope of this quick installation guide. For testing purposes one of
-the above providers should be fine. Please note that network access to the
-OpenID provider you choose is necessary for both you and your Gerrit instance.
-****
-
-
-[[requirements]]
-== Requirements
-
-Most distributions come with Java today. Do you already have Java installed?
-
-----
-  $ java -version
-  openjdk version "1.8.0_72"
-  OpenJDK Runtime Environment (build 1.8.0_72-b15)
-  OpenJDK 64-Bit Server VM (build 25.72-b15, mixed mode)
-----
-
-If Java isn't installed, get it:
-
-* JRE, minimum version 1.8 http://www.oracle.com/technetwork/java/javase/downloads/index.html[Download]
-
-
-[[user]]
-== Create a user to host the Gerrit service
-
-We will run the service as a non-privileged user on your system.
-First create the user and then become the user:
-
-----
-  $ sudo adduser gerrit2
-  $ sudo su gerrit2
-----
-
-If you don't have root privileges you could skip this step and run Gerrit
-as your own user as well.
-
-
-[[download]]
-== Download Gerrit
-
-It's time to download the archive that contains the Gerrit web and ssh service.
-
-You can choose from different versions to download from here:
-
-* https://www.gerritcodereview.com/download/index.html[A list of releases available]
-
-This tutorial is based on version 2.2.2, and you can download that from this link
-
-* https://www.gerritcodereview.com/download/gerrit-2.2.2.war[Link to the 2.2.2 war archive]
-
-
-[[initialization]]
-== Initialize the Site
-
-It's time to run the initialization, and with the batch switch enabled, we don't have to answer any questions at all:
-
-----
-  gerrit2@host:~$ java -jar gerrit.war init --batch -d ~/gerrit_testsite
-  Generating SSH host key ... rsa(simple)... done
-  Initialized /home/gerrit2/gerrit_testsite
-  Executing /home/gerrit2/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit2@host:~$
-----
-
-When the init is complete, you can review your settings in the
-file `'$site_path/etc/gerrit.config'`.
-
-Note that initialization also starts the server.  If any settings changes are
-made, the server must be restarted before they will take effect.
-
-----
-  gerrit2@host:~$ ~/gerrit_testsite/bin/gerrit.sh restart
-  Stopping Gerrit Code Review: OK
-  Starting Gerrit Code Review: OK
-  gerrit2@host:~$
-----
-
-The server can be also stopped and started by passing the `stop` and `start`
-commands to gerrit.sh.
-
-----
-  gerrit2@host:~$ ~/gerrit_testsite/bin/gerrit.sh stop
-  Stopping Gerrit Code Review: OK
-  gerrit2@host:~$
-  gerrit2@host:~$ ~/gerrit_testsite/bin/gerrit.sh start
-  Starting Gerrit Code Review: OK
-  gerrit2@host:~$
-----
-
-include::config-login-register.txt[]
-
-== Project creation
-
-Your base Gerrit server is now running and you have a user that's ready
-to interact with it.  You now have two options, either you create a new
-test project to work with or you already have a git with history that
-you would like to import into Gerrit and try out code review on.
-
-=== New project from scratch
-If you choose to create a new repository from scratch, it's easier for
-you to create a project with an initial commit in it. That way first
-time setup between client and server is easier.
-
-This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project --empty-commit
-  user@host:~$
-----
-
-This will create a repository that you can clone to work with.
-
-=== Already existing project
-
-The other alternative is if you already have a git project that you
-want to try out Gerrit on.
-First you have to create the project.  This is done via the SSH port:
-
-----
-  user@host:~$ ssh -p 29418 user@localhost gerrit create-project demo-project
-  user@host:~$
-----
-
-You need to make sure that at least initially your account is granted
-"Create Reference" privileges for the refs/heads/* reference.
-This is done via the web interface in the Admin/Projects/Access page
-that correspond to your project.
-
-After that it's time to upload the previous history to the server:
-
-----
-  user@host:~/my-project$ git push ssh://user@localhost:29418/demo-project *:*
-  Counting objects: 2011, done.
-  Writing objects: 100% (2011/2011), 456293 bytes, done.
-  Total 2011 (delta 0), reused 0 (delta 0)
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      master -> master
-  user@host:~/my-project$
-----
-
-This will create a repository that you can clone to work with.
-
-
-== My first change
-
-Download a local clone of the repository and move into it
-
-----
-  user@host:~$ git clone ssh://user@localhost:29418/demo-project
-  Cloning into demo-project...
-  remote: Counting objects: 2, done
-  remote: Finding sources: 100% (2/2)
-  remote: Total 2 (delta 0), reused 0 (delta 0)
-  user@host:~$ cd demo-project
-  user@host:~/demo-project$
-----
-
-Then make a change to it and upload it as a reviewable change in Gerrit.
-
-----
-  user@host:~/demo-project$ date > testfile.txt
-  user@host:~/demo-project$ git add testfile.txt
-  user@host:~/demo-project$ git commit -m "My pretty test commit"
-  [master ff643a5] My pretty test commit
-   1 files changed, 1 insertions(+), 0 deletions(-)
-   create mode 100644 testfile.txt
-  user@host:~/demo-project$
-----
-
-Usually when you push to a remote git, you push to the reference
-`'/refs/heads/branch'`, but when working with Gerrit you have to push to a
-virtual branch representing "code review before submission to branch".
-This virtual name space is known as /refs/for/<branch>
-
-----
-  user@host:~/demo-project$ git push origin HEAD:refs/for/master
-  Counting objects: 4, done.
-  Writing objects: 100% (3/3), 293 bytes, done.
-  Total 3 (delta 0), reused 0 (delta 0)
-  remote:
-  remote: New Changes:
-  remote:   http://localhost:8080/1
-  remote:
-  To ssh://user@localhost:29418/demo-project
-   * [new branch]      HEAD -> refs/for/master
-  user@host:~/demo-project$
-----
-
-You should now be able to access your change by browsing to the http URL
-suggested above, http://localhost:8080/1
-
-
-== Quick Installation Complete
-
-This covers the scope of getting Gerrit started and your first change uploaded.
-It doesn't give any clue as to how the review workflow works, please read
-link:http://source.android.com/source/life-of-a-patch[Default Workflow] to
-learn more about the workflow of Gerrit.
-
-To read more on the installation of Gerrit please see link:install.html[the detailed
-installation page].
-
-
-GERRIT
-------
-
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/install.txt b/Documentation/install.txt
index 87d757e..91391bb 100644
--- a/Documentation/install.txt
+++ b/Documentation/install.txt
@@ -79,8 +79,8 @@
 own user account on the host system:
 
 ----
-  sudo adduser gerrit2
-  sudo su gerrit2
+  sudo adduser gerrit
+  sudo su gerrit
 
   java -jar gerrit.war init -d /path/to/your/gerrit_application_directory
 ----
@@ -88,7 +88,7 @@
 [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.
+then give ownership of that location to the `'gerrit'` user.
 
 If run from an interactive terminal, the init command will prompt through a
 series of configuration questions, including gathering information
@@ -106,8 +106,8 @@
 in the background and your web browser will open to the site:
 
 ----
-  Initialized /home/gerrit2/review_site
-  Executing /home/gerrit2/review_site/bin/gerrit.sh start
+  Initialized /home/gerrit/review_site
+  Executing /home/gerrit/review_site/bin/gerrit.sh start
   Starting Gerrit Code Review: OK
   Waiting for server to start ... OK
   Opening browser ...
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
new file mode 100644
index 0000000..ea8c06a
--- /dev/null
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -0,0 +1,289 @@
+= Working with Gerrit: An example
+
+To understand how Gerrit works, let's follow a change through its entire
+life cycle. This example uses a Gerrit server configured as follows:
+
+* *Hostname*: gerrithost
+* *HTTP interface port*: 80
+* *SSH interface port*: 29418
+
+In this walkthrough, we'll follow two developers, Max and Hannah, as they make
+and review a change to a +RecipeBook+ project. We'll follow the change through
+these stages:
+
+. Making the change.
+. Creating the review.
+. Reviewing the change.
+. Reworking the change.
+. Verifying the change.
+. Submitting the change.
+
+NOTE: The project and commands used in this section are for demonstration
+purposes only.
+
+== Making the Change
+
+Our first developer, Max, has decided to make a change to the +RecipeBook+
+project he works on. His first step is to get the source code that he wants to
+modify. To get this code, he runs the following `git clone` command:
+
+----
+clone ssh://gerrithost:29418/RecipeBook.git RecipeBook
+----
+
+After he clones the repository, he runs a couple of commands to add a
+link:user-changeid.html[Change-Id] to his commits. This ID allows Gerrit to link
+together different versions of the same change being reviewed.
+
+....
+scp -p -P 29418 gerrithost:hooks/commit-msg RecipeBook/.git/hooks/
+chmod u+x .git/hooks/commit-msg
+....
+
+NOTE: To learn more about adding a change-id and the commit message hook, see
+the link:cmd-hook-commit-msg.html[commit-msg Hook] topic.
+
+== Creating the Review
+
+Max's next step is to push his change to Gerrit so other contributors can review
+it. He does this using the `git push origin HEAD:refs/for/master` command, as
+follows:
+
+----
+$ <work>
+$ git commit
+[master 3cc9e62] Change to a proper, yeast based pizza dough.
+ 1 file changed, 10 insertions(+), 5 deletions(-)
+$ git push origin HEAD:refs/for/master
+Counting objects: 3, done.
+Delta compression using up to 8 threads.
+Compressing objects: 100% (2/2), done.
+Writing objects: 100% (3/3), 532 bytes | 0 bytes/s, done.
+Total 3 (delta 0), reused 0 (delta 0)
+remote: Processing changes: new: 1, done
+remote:
+remote: New Changes:
+remote:   http://gerrithost/#/c/RecipeBook/+/702 Change to a proper, yeast based pizza dough.
+remote:
+To ssh://gerrithost:29418/RecipeBook
+ * [new branch]      HEAD -> refs/for/master
+----
+
+Notice the reference to a `refs/for/master` branch. Gerrit uses this branch to
+create reviews for the master branch. If Max opted to push to a different
+branch, he would have modified his command to
+`git push origin HEAD:refs/for/<branch_name>`. Gerrit accepts pushes to
+`refs/for/<branch_name>` for every branch that it tracks.
+
+The output of this command also contains a link to a web page Max can use to
+review this commit. Clicking on that link takes him to a screen similar to
+the following.
+
+.Gerrit Code Review Screen
+image::images/intro-quick-new-review.png[Gerrit Review Screen]
+
+This is the Gerrit code review screen, where other contributors can review
+his change. Max can also perform tasks such as:
+
+* Looking at the link:user-review-ui.html#diff-preferences[diff] of his change
+* Writing link:user-review-ui.html#inline-comments[inline] or
+  link:user-review-ui.html#reply[summary] comments to ask reviewers for advice
+  on particular aspects of the change
+* link:intro-user.html#adding-reviewers[Adding a list of people] that should
+  review the change
+
+In this case, Max opts to manually add the senior developer on his team, Hannah,
+to review his change.
+
+== Reviewing the Change
+
+Let's now switch to Hannah, the senior developer who will review Max's change.
+
+As mentioned previously, Max chose to manually add Hannah as a reviewer. Gerrit
+offers other ways for reviewers to find changes, including:
+
+* Using the link:user-search.html[search] feature that to find changes
+* Selecting *Open* from the *Changes* menu
+* Setting up link:user-notify.html[email notifications] to stay informed of
+  changes even if you are not added as a reviewer
+
+Because Max added Hannah as a reviewer, she receives an email telling her about
+his change. She opens up the Gerrit code review screen and selects Max's change.
+
+Notice the *Label status* section above:
+
+----
+Label Status Needs label:
+             * Code-Review
+             * Verified
+----
+
+These two lines indicate what checks must be completed before the change is
+accepted. The default Gerrit workflow requires two checks:
+
+* *Code-Review*. This check requires that someone look at the code and ensures
+  that it meets project guidelines, styles, and other criteria.
+* *Verified*. This check means that the code actually compiles, passes any unit
+  tests, and performs as expected.
+
+In general, the *Code-Review* check requires an individual to look at the code,
+while the *Verified* check is done by an automated build server, through a
+mechanism such as the
+link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger
+Jenkins Plugin].
+
+IMPORTANT: The Code-Review and Verified checks require different permissions
+in Gerrit. This requirement allows teams to separate these tasks. For example,
+an automated process can have the rights to verify a change, but not perform a
+code review.
+
+With the code review screen open, Hannah can begin to review Max's change. She
+can choose one of two ways to review the change: unified or side-by-side.
+Both views allow her to perform tasks such as add
+link:user-review-ui.html#inline-comments[inline] or
+link:user-review-ui.html#reply[summary] comments.
+
+Hannah opts to view the change using Gerrit's side-by-side view:
+
+.Side By Side Patch View
+image::images/intro-quick-review-line-comment.png[Adding a Comment]
+
+Hannah reviews the change and is ready to provide her feedback. She clicks the
+*REPLY* button on the change screen. This allows her to vote on the change.
+
+.Reviewing the Change
+image::images/intro-quick-reviewing-the-change.png[Reviewing the Change]
+
+For Hannah and Max's team, a code review vote is a numerical score between -2
+and 2. The possible options are:
+
+* `+2 Looks good to me, approved`
+* `+1 Looks good to me, but someone else must approve`
+* `0 No score`
+* `-1 I would prefer that you didn't submit this`
+* `-2 Do not submit`
+
+In addition, a change must have at least one `+2` vote and no `-2` votes before
+it can be submitted. These numerical values do not accumulate. Two
+`+1` votes do not equate to a `+2`.
+
+NOTE: These settings are enabled by default. To learn about how to customize
+them for your own workflow, see the
+link:config-project-config.html[Project Configuration File Format] topic.
+
+Hannah notices a possible issue with Max's change, so she selects a `-1` vote.
+She uses the *Cover Message* text box to provide Max with some additional
+feedback. When she is satisfied with her review, Hannah clicks the
+*SEND* button. At this point, her vote and cover message become
+visible to to all users.
+
+== Reworking the Change
+
+Later in the day, Max decides to check on his change and notices Hannah's
+feedback. He opens up the source file and incorporates her feedback. Because
+Max's change includes a change-id, all he has to is follow the typical git
+workflow for updating a commit:
+
+* Check out the commit
+* Amend the commit
+* Push the commit to Gerrit
+
+----
+$ <checkout first commit>
+$ <rework>
+$ git commit --amend
+[master 30a6f44] Change to a proper, yeast based pizza dough.
+ Date: Fri Jun 8 16:28:23 2018 +0200
+ 1 file changed, 10 insertions(+), 5 deletions(-)
+$ git push origin HEAD:refs/for/master
+Counting objects: 3, done.
+Delta compression using up to 8 threads.
+Compressing objects: 100% (2/2), done.
+Writing objects: 100% (3/3), 528 bytes | 0 bytes/s, done.
+Total 3 (delta 0), reused 0 (delta 0)
+remote: Processing changes: updated: 1, done
+remote:
+remote: Updated Changes:
+remote:   http://gerrithost/#/c/RecipeBook/+/702 Change to a proper, yeast based pizza dough.
+remote:
+To ssh://gerrithost:29418/RecipeBook
+ * [new branch]      HEAD -> refs/for/master
+----
+
+Notice that the output of this command is slightly different from Max's first
+commit. This time, the output verifies that the change was updated.
+
+Having uploaded the reworked commit, Max can go back to the Gerrit web
+interface, look at his change and diff the first patch set with his rework in
+the second one. Once he has verified that the rework follows Hannahs
+recommendation he presses the *DONE* button to let Hannah know that she can
+review the changes.
+
+When Hannah next looks at Max's change, she sees that he incorporated her
+feedback. The change looks good to her, so she changes her vote to a `+2`.
+
+== Verifying the Change
+
+Hannah's `+2` vote means that Max's change satisfies the *Needs Review*
+check. It has to pass one more check before it can be accepted: the *Needs
+Verified* check.
+
+The Verified check means that the change was confirmed to work. This type of
+check typically involves tasks such as checking that the code compiles, unit
+tests pass, and other actions. You can configure a Verified check to consist
+of as many or as few tasks as needed.
+
+NOTE: Remember that this walkthrough uses Gerrit's default workflow. Projects
+can add custom checks or even remove the Verified check entirely.
+
+Verification is typically an automated process using the
+link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger Jenkins Plugin]
+or a similar mechanism. However, there are still times when a change requires
+manual verification, or a reviewer needs to check how or if a change works.
+To accommodate these and other similar circumstances, Gerrit exposes each change
+as a git branch. The Gerrit UI includes a
+link:user-review-us.html#download[*download*] link in the Gerrit Code Review
+Screen to make it easy for reviewers to fetch a branch for a specific change.
+To manually verify a change, a reviewer must have the
+link:config-labels.html#label_Verified[Verified] permission. Then, the reviewer
+can fetch and checkout that branch from Gerrit. Hannah has this permission, so
+she is authorized to manually verify Max's change.
+
+NOTE: The Verifier can be the same person as the code reviewer or a
+different person entirely.
+
+.Verifying the Change
+image::images/intro-quick-verifying.png[Verifying the Change]
+
+Unlike the code review check, the verify check is pass/fail. Hannah can provide
+a score of either `+1` or `-1`. A change must have at least one `+1` and no
+`-1`.
+
+Hannah selects a `+1` for her verified check. Max's change is now ready to be
+submitted.
+
+== Submitting the Change
+
+Max is now ready to submit his change. He opens up the change in the Code Review
+screen and clicks the *SUBMIT* button.
+
+At this point, Max's change is merged into the repository's master branch and
+becomes an accepted part of the project.
+
+== Next Steps
+
+This walkthrough provided a quick overview of how a change moves
+through the default Gerrit workflow. At this point, you can:
+
+* Read the link:intro-user.html[Users guide] to get a better sense of how to
+  make changes using Gerrit
+* Review the link:intro-project-owner.html[Project Owners guide] to learn more
+  about configuring projects in Gerrit, including setting user permissions and
+  configuring verification checks
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/intro-how-gerrit-works.txt b/Documentation/intro-how-gerrit-works.txt
new file mode 100644
index 0000000..f903849
--- /dev/null
+++ b/Documentation/intro-how-gerrit-works.txt
@@ -0,0 +1,37 @@
+= How Gerrit Works
+
+To understand how Gerrit fits into and enhances the developer workflow, consider
+a typical project. This project has a central source repository, which serves as
+the authoritative copy of the project's contents.
+
+.Central Source Repository
+image::images/intro-quick-central-repo.png[Authoritative Source Repository]
+
+Gerrit takes the place of this central repository and adds an additional
+concept: a _store of pending changes_.
+
+.Gerrit in place of Central Repository
+image::images/intro-quick-central-gerrit.png[Gerrit in place of Central Repository]
+
+With Gerrit, when a developer makes a change, it is sent to this store of
+pending changes, where other developers can review, discuss and approve the
+change. After enough reviewers grant their approval, the change becomes an
+official part of the codebase.
+
+In addition to this store of pending changes, Gerrit captures notes
+and comments about each change. These features allow developers to review
+changes at their convenience, or when conversations about a change can't
+happen face to face. They also help to create a record of the conversation
+around a given change, which can provide a history of when a change was made and
+why.
+
+Like any repository hosting solution, Gerrit has a powerful
+link:access-control.html[access control model]. This model allows you to
+fine-tune access to your repository.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 38cfeac..8a3529e 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -173,7 +173,7 @@
 them, e.g. link:access-control.html#ldap_groups[LDAP group names] need
 to be prefixed with `ldap/`.
 
-If the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/singleusergroup[
+If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/singleusergroup[
 singleusergroup] plugin is installed you can also directly assign
 access rights to users, by prefixing the username with `user/` or the
 user's account ID by `userid/`.
@@ -379,7 +379,7 @@
 Create Account] global capability is granted. If not, you need to ask
 a Gerrit administrator to create the service user.
 
-If the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/serviceuser[
+If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/serviceuser[
 serviceuser] plugin is installed you can also create new service users
 in the Gerrit Web UI under `People` > `Create Service User`. For this
 the `Create Service User` global capability must be assigned.
@@ -407,13 +407,13 @@
 
 There are some plugins available that provide commit validation:
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/uploadvalidator[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/uploadvalidator[
   uploadvalidator]:
 +
 The `uploadvalidator` plugin allows project owners to configure blocked
 file extensions, required footers and a maximum allowed path length.
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/commit-message-length-validator[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/commit-message-length-validator[
   commit-message-length-validator]
 +
 The `commit-message-length-validator` core plugin validates that commit
@@ -494,9 +494,9 @@
 - Issue Tracker System Plugins
 +
 There are Gerrit plugins for a tight integration with
-link:https://gerrit-review.googlesource.com/\#/admin/projects/plugins/its-jira[Jira],
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-bugzilla[Bugzilla] and
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-rtc[IBM Rational Team Concert].
+link:https://gerrit-review.googlesource.com//admin/repos/plugins/its-jira[Jira],
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-bugzilla[Bugzilla] and
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/its-rtc[IBM Rational Team Concert].
 If installed, these plugins can e.g. be used to automatically add links
 to Gerrit changes to the issues in the issue tracker system or to
 automatically close an issue if the corresponding change is merged.
@@ -543,13 +543,13 @@
 by adding this person in the Gerrit Web UI as a reviewer on the change.
 Gerrit will then notify this person by email about the review request.
 
-With the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewers[
+With the link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers[
 reviewers] plugin it is possible to configure default reviewers who
 will be automatically added to each change. The default reviewers can
 be configured in the Gerrit Web UI under `Projects` > `List` >
 <your project> > `General` in the `reviewers Plugin` section.
 
-The link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/reviewers-by-blame[
+The link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers-by-blame[
 reviewers-by-blame] plugin can automatically add reviewers to changes
 based on the link:https://www.kernel.org/pub/software/scm/git/docs/git-blame.html[
 git blame] computation on the changed files. This means that the plugin
@@ -570,7 +570,7 @@
 that the available download commands depend on the installed Gerrit
 plugins:
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/download-commands[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/download-commands[
   download-commands] plugin:
 +
 The `download-commands` plugin provides the default download commands
@@ -579,14 +579,14 @@
 Gerrit administrators may configure which of the commands are shown on
 the change screen.
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/egit[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/egit[
   egit] plugin:
 +
 The `egit` plugin provides the change ref as a download command, which is
 needed for downloading a change from within
 link:https://www.eclipse.org/egit/[EGit].
 
-- link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/project-download-commands[
+- link:https://gerrit-review.googlesource.com/admin/repos/plugins/project-download-commands[
   project-download-commands] plugin:
 +
 The `project-download-commands` plugin enables project owners to
@@ -745,7 +745,7 @@
 
 Gerrit core does not support the deletion of projects.
 
-If the link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/delete-project[
+If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/delete-project[
 delete-project] plugin is installed, projects can be deleted from the
 Gerrit Web UI under `Projects` > `List` > <project> > `General` by
 clicking on the `Delete` command under `Project Commands`. The `Delete`
diff --git a/Documentation/intro-quick.txt b/Documentation/intro-quick.txt
index d72c696..e6b1e43 100644
--- a/Documentation/intro-quick.txt
+++ b/Documentation/intro-quick.txt
@@ -1,388 +1,35 @@
-= Gerrit Code Review - A Quick Introduction
+= Gerrit Product Overview
 
-Gerrit is a web-based code review tool built on top of the git version
-control system, but if you've got as far as reading this guide then
-you probably already know that. The purpose of this introduction is to
-allow you to answer the question, is Gerrit the right tool for me?
-Will it fit in my work flow and in my organization?
+Gerrit is a web-based code review tool built on top of the
+https://git-scm.com/[Git version control system]. This introduction provides
+an overview of Gerrit and describes how Gerrit integrates into a typical
+development workflow. It also provides a brief tutorial that shows how to manage
+a change using Gerrit.
 
 == What is Gerrit?
 
-It is assumed that if you're reading this then you're already convinced
-of the benefits of code review in general but want some technical support
-to make it easy.
+Gerrit makes code review easy by providing a lightweight framework for reviewing
+commits before they are accepted by the codebase. Gerrit works equally well for
+projects where approving changes is restricted to selected users, as is typical
+for Open Source software development, as well as projects where all contributors
+are trusted.
 
-Code reviews mean different things to different people. To some it's a
-formal meeting with a projector and an entire team going through the code
-line by line. To others it's getting someone to glance over the code before
-it is committed.
+== Learn About Gerrit
 
-Gerrit is intended to provide a lightweight framework for reviewing
-every commit before it is accepted into the code base. Changes are
-uploaded to Gerrit but don't actually become a part of the project
-until they've been reviewed and accepted. In many ways this is simply
-tooling to support the standard open source process of submitting
-patches which are then reviewed by the project members before being
-applied to the code base. However Gerrit goes a step further making it
-simple for all committers on a project to ensure that changes are
-checked over before they're actually applied. Because of this Gerrit
-is equally useful where all users are trusted committers such as may
-be the case with closed-source commercial development. Either way it's
-still desirable to have code reviewed to improve the quality and
-maintainability of the code. After all, if only one person has seen
-the code it may be a little difficult to maintain when that person
-leaves.
+If you're new to Gerrit and want to know more about how it can improve your
+developer workflow, see the following topics:
 
-Gerrit is firstly a staging area where changes can be checked over
-before becoming a part of the code base. It is also an enabler for
-this review process, capturing notes and comments about the changes to
-enable discussion of the change. This is particularly useful with
-distributed teams where this conversation can't happen face to face.
-Even with a co-located team having a review tool as an option is
-beneficial because reviews can be done at a time that is convenient
-for the reviewer. This allows the developer to create the review and
-explain the change while it is fresh in their mind. Without such a
-tool they either need to interrupt someone to review the code or
-switch context to explain the change when they've already moved on to
-the next task.
+. link:intro-how-gerrit-works.html[How Gerrit Works]
+. link:intro-gerrit-walkthrough.html[Basic Gerrit Walkthrough]
 
-This also creates a lasting record of the conversation which can be
-useful for answering the inevitable "I know we changed this for a
-reason" questions.
+== Getting Started
 
-== Where does Gerrit fit in?
+This documentation contains several guides to help you learn about the Gerrit
+features most relevant to you:
 
-Any team with more than one member has a central source repository of
-some kind (or they should). Git can theoretically work without such a
-central location but in practice there is usually a central
-repository. This serves as the authoritative copy of what is actually in
-the project. This is what everyone fetches from and pushes to and is
-generally where build servers and other such tools get the source
-from.
-
-.Central Source Repository
-image::images/intro-quick-central-repo.png[Authoritative Source Repository]
-
-Gerrit is deployed in place of this central repository and adds an
-additional concept, a store of pending changes. Everyone still fetches
-from the authoritative repository but instead of pushing back to it,
-they push to this pending changes location. A change can only be submitted
-into the authoritative repository and become an accepted part of the project
-once the change has been reviewed and approved.
-
-.Gerrit in place of Central Repository
-image::images/intro-quick-central-gerrit.png[Gerrit in place of Central Repository]
-
-Like any repository hosting solution, Gerrit has a powerful
-link:access-control.html[access control model.]
-Users can even be granted access to push directly into the central
-repository, bypassing code review entirely. Gerrit can even be used
-without code review, used simply to host the repositories and
-controlling access. But generally it's just simpler and safer to go
-through the review process even for users who are allowed to directly
-push.
-
-== The Life and Times of a Change
-
-The easiest way to get a feel for how Gerrit works is to follow a
-change through its entire life cycle. For the purpose of this example
-we'll assume that the Gerrit Server is running on a server called
-+gerrithost+ with the HTTP interface on port +8080+ and the SSH
-interface on port +29418+. The project we'll be working on is called
-+RecipeBook+ and we'll be developing a change for the +master+ branch.
-
-=== Cloning the Repository
-
-Obviously the first thing we need to do is get the source that we're
-going to be modifying. As with any git project you do this by cloning
-the central repository that Gerrit is hosting. e.g.
-
-----
-$ git clone ssh://gerrithost:29418/RecipeBook.git RecipeBook
-Cloning into RecipeBook...
-----
-
-Then we need to make our actual change and commit it locally. Gerrit
-doesn't really change anything here, this is just the standard editing
-and git. While not strictly required, it's best to include a Change-Id
-in your commit message so that Gerrit can link together different
-versions of the same change being reviewed. Gerrit contains a standard
-link:user-changeid.html[Change-Id commit-msg hook]
-that will generate a unique Change-Id when you commit. If you don't do
-this then Gerrit will generate a Change-Id when you push your change
-for review. But because you don't have the Change-Id in your commit
-message you'll need to manually copy it in if you need to upload
-another version of your change. Because of this it's best to just
-install the hook and forget about it.
-
-=== Creating the Review
-
-Once you've made your change and committed it locally it's time to
-push it to Gerrit so that it can be reviewed. This is done with a git
-push to the Gerrit server. Since we cloned our local repository
-directly from Gerrit it is the origin so we don't have to redefine the
-remote.
-
-----
-$ <work>
-$ git commit
-[master 9651f22] Change to a proper, yeast based pizza dough.
- 1 files changed, 3 insertions(+), 2 deletions(-)
-$ git push origin HEAD:refs/for/master
-Counting objects: 5, done.
-Delta compression using up to 8 threads.
-Compressing objects: 100% (2/2), done.
-Writing objects: 100% (3/3), 542 bytes, done.
-Total 3 (delta 0), reused 0 (delta 0)
-remote:
-remote: New Changes:
-remote:   http://gerrithost:8080/68
-remote:
-To ssh://gerrithost:29418/RecipeBook.git
- * [new branch]      HEAD -> refs/for/master
-----
-
-The only different thing about this is the +refs/for/master+ branch.
-This is a magic branch that creates reviews that target the master
-branch. For every branch Gerrit tracks there is a magic
-+refs/for/<branch_name>+ that you push to to create reviews.
-
-In the output of this command you'll notice that there is a link to
-the HTTP interface of the Gerrit server we just pushed to. This is the
-web interface where we will review this commit. Let's follow that link
-and see what we get.
-
-.Gerrit Code Review Screen
-image::images/intro-quick-new-review.jpg[Gerrit Review Screen]
-
-This is the Gerrit code review screen where someone will come to
-review the change. There isn't too much to see here yet, you can look
-at the diff of your change, add some comments explaining what you did
-and why, you may even add a list of people that should review the change.
-
-Reviewers can find changes that they want to review in any number of
-ways. Gerrit has a capable link:user-search.html[search]
-that allows project leaders (or anyone else) to find changes that need
-to be reviewed. Users can also setup watches on Gerrit projects with a
-search expression, this causes Gerrit to notify them of matching
-changes. So adding a reviewer when creating a review is just a
-recommendation.
-
-At this point the change is available for review and we need to switch
-roles to continue following the change. Now let's pretend we're the
-reviewer.
-
-=== Reviewing the Change
-
-The reviewer's life starts at the code review screen shown above. He
-can get here in a number of ways, but for some reason they've decided
-to review this change. Of particular note on this screen are the two
-"Need" lines:
-
-----
-* Need Verified
-* Need Code-Review
-----
-
-Gerrit's default work-flow requires two checks before a change is
-accepted. Code-Review is someone looking at the code, ensuring it
-meets the project guidelines, intent etc. Verifying is checking that
-the code actually compiles, unit tests pass etc. Verification is
-usually done by an automated build server rather than a person. There
-is even a
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger Jenkins Plugin]
-that will automatically build each uploaded change and update the
-verified score accordingly.
-
-It is important to note that Code-Review and Verification are
-different permissions in Gerrit, allowing these tasks to be separated.
-For example, an automated process would have rights to verify but not
-to code-review.
-
-Since we are the code reviewer, we're going to review the code. To do
-this we can view it within the Gerrit web interface as either a
-unified or side-by-side diff by selecting the appropriate option. In
-the example below we've selected the side-by-side view. In either of
-these views you can add inline comments by double clicking on the line
-(or single click the line number) that you want to comment on. Also you
-can add file comment by double clicking anywhere (not just on the
-"Patch Set" words) in the table header or single clicking on the icon
-in the line-number column header. Once published these comments are
-visible to all, allowing discussion of the change to take place.
-
-.Side By Side Patch View
-image::images/intro-quick-review-line-comment.jpg[Adding a Comment]
-
-Code reviewers end up spending a lot of time navigating these screens,
-looking at and commenting on these changes. To make this as efficient
-as possible Gerrit has keyboard shortcuts for most operations (and
-even some operations that are only accessible via the hot-keys). At
-any time you can hit the +?+ key to see the keyboard shortcuts.
-
-.Gerrit Hot Key Help
-image::images/intro-quick-hot-key-help.jpg[Hot Key Help]
-
-Once we've looked over the changes we need to complete reviewing the
-submission. To do this we click the _Review_ button on the change
-screen where we started. This allows us to enter a Code Review label
-and message.
-
-.Reviewing the Change
-image::images/intro-quick-reviewing-the-change.jpg[Reviewing the Change]
-
-The label that the reviewer selects determines what can happen next.
-The +1 and -1 level are just an opinion where as the +2 and -2 levels
-are allowing or blocking the change. In order for a change to be
-accepted it must have at least one +2 and no -2 votes.
-Although these are numeric values, they in no way accumulate;
-two +1s do not equate to a +2.
-
-Regardless of what label is selected, once the _Publish Comments_
-button has been clicked, the cover message and any comments on the
-files become visible to all users.
-
-In this case the change was not accepted so the creator needs to
-rework it. So let's switch roles back to the creator where we
-started.
-
-=== Reworking the Change
-
-As long as we set up the
-link:user-changeid.html[Change-Id commit-msg hook]
-before we uploaded the change, re-working it is easy. All we need
-to do to upload a re-worked change is to push another commit that has
-the same Change-Id in the message. Since the hook added a Change-Id in
-our initial commit we can simply checkout and then amend that commit.
-Then push it to Gerrit in the same way as we did to create the review. E.g.
-
-----
-$ <checkout first commit>
-$ <rework>
-$ git commit --amend
-$ git push origin HEAD:refs/for/master
-Counting objects: 5, done.
-Delta compression using up to 8 threads.
-Compressing objects: 100% (2/2), done.
-Writing objects: 100% (3/3), 546 bytes, done.
-Total 3 (delta 0), reused 0 (delta 0)
-remote: Processing changes: updated: 1, done
-remote:
-remote: Updated Changes:
-remote:   http://gerrithost:8080/68
-remote:
-To ssh://gerrithost:29418/RecipeBook.git
- * [new branch]      HEAD -> refs/for/master
-----
-
-Note that the output is slightly different this time around. Since
-we're adding to an existing review it tells us that the change was
-updated.
-
-Having uploaded the reworked commit we can go back into the Gerrit web
-interface and look at our change.
-
-.Reviewing the Rework
-image::images/intro-quick-review-2-patches.jpg[Reviewing the Rework]
-
-If you look closely you'll notice that there are now two patch sets
-associated with this change, the initial submission and the rework.
-Rather than repeating ourselves lets assume that this time around the
-patch is given a +2 score by the code reviewer.
-
-=== Trying out the Change
-
-With Gerrit's default work-flow there are two sign-offs, code review
-and verify. Verifying means checking that the change actually works.
-This would typically be checking that the code compiles, unit tests
-pass and similar checks. Really a project can decide how much or
-little they want to do here. It's also worth noting that this is only
-Gerrit's default work-flow, the verify check can actually be removed
-or others added.
-
-As mentioned in the code review section, verification is typically an
-automated process using the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger Jenkins Plugin]
-or similar. But there are times when the code needs to be manually
-verified, or the reviewer needs to check that something actually works
-or how it works. Sometimes it's just nice to work through the code in a
-development environment rather than the web interface. All of these
-involve someone needing to get the change into their development
-environment. Gerrit makes this process easy by exposing each change as
-a git branch. So all the reviewers need to do is fetch and checkout that
-branch from Gerrit and they will have the change.
-
-We don't even need to think about it that hard, if you look at the
-earlier screenshots of the Gerrit Code Review Screen you'll notice a
-_download_ command. All we need to do to get the change is copy
-paste this command and run it in our Gerrit checkout.
-
-----
-$ git fetch ssh://gerrithost:29418/RecipeBook refs/changes/68/68/2
-From ssh://gerrithost:29418/RecipeBook
- * branch            refs/changes/68/68/2 -> FETCH_HEAD
-$ git checkout FETCH_HEAD
-Note: checking out 'FETCH_HEAD'.
-
-You are in 'detached HEAD' state. You can look around, make experimental
-changes and commit them, and you can discard any commits you make in this
-state without impacting any branches by performing another checkout.
-
-If you want to create a new branch to retain commits you create, you may
-do so (now or later) by using -b with the checkout command again. Example:
-
-  git checkout -b new_branch_name
-
-HEAD is now at d5dacdb... Change to a proper, yeast based pizza dough.
-----
-
-Easy as that, we now have the change in our working copy to play with.
-You might be interested in what the numbers of the refspec mean.
-
-* The first *68* is the id of the change +mod 100+.  The only reason
-for this initial number is to reduce the number of files in any given
-directory within the git repository.
-* The second *68* is the full id of the change. You'll notice this in
-the URL of the Gerrit review screen.
-* The *2* is the patch-set within the change. In this example we
-uploaded some fixes so we want the second patch set rather than the
-initial one which the reviewer rejected.
-
-=== Manually Verifying the Change
-
-For simplicity we're just going to manually verify the change.
-The Verifier may be the same person as the code reviewer or a
-different person entirely. It really depends on the size of the
-project and what works. If you have Verify permission then when you
-click the _Review_ button in the Gerrit web interface you'll be
-presented with a verify score.
-
-.Verifying the Change
-image::images/intro-quick-verifying.jpg[Verifying the Change]
-
-Unlike the code review the verify check doesn't have a +2 or -2 level,
-it's either a pass or fail so all we need for the change to be
-submitted is a +1 score (and no -1's).
-
-=== Submitting the Change
-
-You might have noticed that in the verify screen shot there are two
-buttons for submitting the score _Publish Comments_ and _Publish
-and Submit_. The publish and submit button is always visible, but will
-only work if the change meets the criteria for being submitted (I.e.
-has been both verified and code reviewed). So it's a convenience to be
-able to post review scores as well as submitting the change by clicking
-a single button. If you choose just to publish comments at this point then
-the score will be stored but the change won't yet be accepted into the code
-base. In this case there will be a _Submit Patch Set X_ button on the
-main screen. Just as Code-Review and Verify are different operations
-that can be done by different users, Submission is a third operation
-that can be limited down to another group of users.
-
-Clicking the _Publish and Submit_ or _Submit Patch Set X_ button
-will merge the change into the main part of the repository so that it
-becomes an accepted part of the project. After this anyone fetching
-the git repository will receive this change as a part of the master
-branch.
+. link:intro-user.html[User Guide]
+. link:intro-project-owner.html[Project Owner Guide]
+. link:https://source.android.com/source/life-of-a-patch[Default Android Workflow] (external)
 
 GERRIT
 ------
@@ -390,3 +37,4 @@
 
 SEARCHBOX
 ---------
+
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 86962b9..857e04c 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -474,6 +474,10 @@
 the use of the command line flag `--push-option`, aliased to `-o`,
 followed by `topic=...`.
 
+Gerrit may be link:config-gerrit.html#change.submitWholeTopic[configured] to
+submit all changes in a topic together with a single click, even when topics
+span multiple projects.
+
 .Set Topic on Push
 ----
   $ git push origin HEAD:refs/for/master%topic=multi-master
@@ -482,39 +486,137 @@
   $ git push origin HEAD:refs/heads/master -o topic=multi-master
 ----
 
-[[drafts]]
-== Working with Drafts
+[[hashtags]]
+== Using Hashtags
 
-Changes can be uploaded as drafts. By default draft changes are only
-visible to the change owner. This gives you the possibility to have
-some staging before making your changes visible to the reviewers. Draft
-changes can also be used to backup unfinished changes.
+Hashtags are freeform strings associated with a change, like on social media
+platforms. In Gerrit, you explicitly associate hashtags with changes using a
+dedicated area of the UI; they are not parsed from commit messages or comments.
 
-A draft change is created by pushing to the magic
-`refs/drafts/<target-branch>` ref, or by pushing with the 'draft'
-option to `refs/for/<target-branch>%draft`.
+Similar to topics, hashtags can be used to group related changes together, and
+to search using the link:user-search.html#hashtag[`hashtag:`] operator. Unlike
+topics, a change can have multiple hashtags, and they are only used for
+informational grouping; changes with the same hashtags are not necessarily
+submitted together.
 
-.Push a Draft Change
+The hashtag feature is only available when running under
+link:note-db.html[NoteDb].
+
+.Set Hashtag on Push
+----
+  $ git push origin HEAD:refs/for/master%t=stable-bugfix
+
+  // this is the same as:
+  $ git push origin HEAD:refs/heads/master -o t=stable-bugfix
+----
+
+[[wip]]
+== Work-in-Progress Changes
+
+Work-in-Progress (WIP) changes are visible to anyone, but do not notify or
+require an action from a reviewer.
+
+Specifically, when you mark a change as Work-in-Progress:
+
+* Reviewers are not notified for most operations, such as adding or removing,
+  posting comments, and so on. See the REST API documentation
+  link:rest-api-changes.html#set-review-notifications[tables] for more
+  information.
+* The change does not show in reviewers' dashboards.
+
+WIP changes are useful when:
+
+* You have implemented only part of a change, but want to push your change
+  to the server to run tests or perform other actions before requesting
+  reviewer feedback.
+* During a review, you realize you need to rework your change, and you
+  want to stop notifying reviewers of the change until you finish your
+  update.
+
+To set the status of a change to Work-in-Progress, you can use either
+the command line or the user interface. To use the command line, append
+`%wip` to your push request.
+
+----
+  $ git push origin HEAD:refs/for/master%wip
+----
+Alternatively, click *WIP* from the Change screen.
+
+To mark the change as ready for review, append `%ready` to your push
+request.
+
+----
+  $ git push origin HEAD:refs/for/master%ready
+----
+Alternatively, click *Ready* from the Change screen.
+
+Only change owners, project owners and site administrators can mark changes as
+`work-in-progress` and `ready`.
+
+[[wip-polygerrit]]
+In the new PolyGerrit UI, you can mark a change as WIP, by selecting *WIP* from
+the *More* menu. The Change screen updates with a yellow header, indicating that
+the change is in a Work-in-Progress state. To mark a change as ready for review,
+click *Start Review*.
+
+[[private-changes]]
+== Private Changes
+
+Private changes are changes that are only visible to their owners and
+reviewers. Private changes are useful in a number of cases:
+
+* You want a set of collaborators to review the change before formal review
+  starts. By creating a Private change and adding only a selected few as
+  reviewers you can control who can see the change and get a first opinion
+  before opening up for all reviewers.
+
+* You want to check what the change looks like before formal review starts.
+  By marking the change private without reviewers, nobody can
+  prematurely comment on your changes.
+
+* You want to use Gerrit to sync data between different devices. By
+  creating a private throwaway change without reviewers, you can push
+  from one device, and fetch to another device.
+
+* You want to do code review on a change that has sensitive
+  aspects. By reviewing a security fix in a private change,
+  outsiders can't discover the fix before it is pushed out. Even after
+  merging the change, the review can be kept private.
+
+To create a private change, you push it with the `private` option.
+
+.Push a private change
 ----
   $ git commit
-  $ git push origin HEAD:refs/drafts/master
-  # or
-  $ git push origin HEAD:refs/for/master%draft
+  $ git push origin HEAD:refs/for/master%private
 ----
 
-Draft changes have the state link:user-review-ui.html#draft[Draft] and
-can be link:user-review-ui.html#publish[published] or
-link:user-review-ui.html#delete[deleted] from the change screen.
+The change will remain private on subsequent pushes until you specify
+the `remove-private` option. Alternatively, the web UI provides buttons
+to mark a change private and non-private again.
 
-By link:user-review-ui.html#reviewers[adding reviewers] to a draft
-change the change is made visible to these users. This way you can
-collaborate with other users in privacy.
+When pushing a private change with a commit that is authored by another
+user, the other user will not be automatically added as a reviewer and
+must be explicitly added.
 
-By pushing to `refs/drafts/<target-branch>` you can also upload draft
-patch sets to non-draft changes. Draft patch sets are immediately
-visible to all reviewers of the change, but other users cannot see the
-draft patch set. A draft patch set can be published and deleted in the
-same way as a draft change.
+For CI systems that must verify private changes, a special permission
+can be granted
+(link:access-control.html#category_view_private_changes[View Private Changes]).
+In that case, care should be taken to prevent the CI system from
+exposing secret details.
+
+[[ignore]]
+== Ignoring Or Marking Changes As 'Reviewed'
+
+Changes can be ignored, which means they will not appear in the 'Incoming
+Reviews' dashboard and any related email notifications will be suppressed.
+This can be useful when you are added as a reviewer to a change on which
+you do not actively participate in the review, but do not want to completely
+remove yourself.
+
+Alternatively, rather than completely ignoring the change, it can be marked
+as 'Reviewed'. Marking a change as 'Reviewed' means it will not be highlighted
+in the dashboard, until a new patch set is uploaded.
 
 [[inline-edit]]
 == Inline Edit
@@ -638,7 +740,7 @@
 +
 Email notifications are enabled.
 +
-** [[cc-me]]`CC Me On Comments I Write`:
+** [[cc-me]]`Every comment`:
 +
 Email notifications are enabled and you get notified by email as CC
 on comments that you write yourself.
@@ -716,6 +818,12 @@
 and `Edit Config` buttons on the project screen, and the `Follow-Up`
 button on the change screen).
 
+- [[publish-comments-on-push]]`Publish Draft Comments When a Change Is Updated by Push`:
++
+Whether to publish any outstanding draft comments by default when pushing
+updates to open changes. This preference just sets the default; the behavior can
+still be overridden using a link:user-upload.html#publish-comments[push option].
+
 - [[use-flash]]`Use Flash Clipboard Widget`:
 +
 Whether the Flash clipboard widget should be used. If enabled and the Flash
@@ -724,6 +832,12 @@
 and download commands. Note that this option is only shown if the Flash plugin
 is available and the JavaScript Clipboard API is unavailable.
 
+- [[work-in-progress-by-default]]`Set new changes work-in-progress`:
++
+Whether new changes are uploaded as work-in-progress per default. This
+preference just sets the default; the behavior can still be overridden using a
+link:user-upload.html#wip[push option].
+
 [[my-menu]]
 In addition it is possible to customize the menu entries of the `My`
 menu. This can be used to make the navigation to frequently used
diff --git a/Documentation/json.txt b/Documentation/json.txt
index fa61d01..533affe 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -41,12 +41,16 @@
 
   NEW;; Change is still being reviewed.
 
-  DRAFT;; Change is a draft change that only consists of draft patchsets.
-
   MERGED;; Change has been merged to its branch.
 
   ABANDONED;; Change was abandoned by its owner or administrator.
 
+private:: Boolean indicating if the change is
+link:intro-user.html#private-changes[private].
+
+wip:: Boolean indicating if the change is
+link:intro-user.html#wip[work in progress].
+
 comments:: All inline/file comments for this change in <<message,message attributes>>.
 
 trackingIds:: Issue tracking system links in
@@ -108,8 +112,6 @@
 createdOn:: Time in seconds since the UNIX epoch when this patchset
 was created.
 
-isDraft:: Whether or not the patch set is a draft patch set.
-
 kind:: Kind of change uploaded.
 
   REWORK;; Nontrivial content changes.
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
new file mode 100644
index 0000000..ee7fb16
--- /dev/null
+++ b/Documentation/linux-quickstart.txt
@@ -0,0 +1,116 @@
+= Quickstart for Installing Gerrit on Linux
+
+This quickstart shows you how to install Gerrit on a Linux machine.
+
+[NOTE]
+====
+The installation steps provided in this quickstart are for
+demonstration purposes only. They are not intended for use in a production
+environment.
+
+For a more detailed installation guide, see
+link:install.html[Standalone Daemon Installation Guide].
+====
+
+== Before you begin
+
+To complete this quickstart, you need:
+
+. A Unix-based server such as any of the Linux flavors or BSD.
+. Java SE Runtime Environment version 1.8
++
+Gerrit is not compatible with Java 9 or newer yet.
+
+== Download Gerrit
+
+From the Linux machine on which you want to install Gerrit:
+
+. Open a terminal window.
+. Download the Gerrit archive. See
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code
+Review - Releases] for a list of available archives.
+
+The steps in this quickstart used Gerrrit 2.14.2, which you can download using
+a command such as:
+
+....
+wget https://www.gerritcodereview.com/download/gerrit-2.14.2.war
+....
+
+NOTE: If you want to build and install Gerrit from the source files, see
+link:dev-readme.html[Developer Setup].
+
+== Install and initialize Gerrit
+
+From the command line, type the following:
+
+....
+java -jar gerrit*.war init --batch --dev -d ~/gerrit_testsite
+....
+
+The preceding command uses two parameters:
+
+* `--batch`. This parameter assigns default values to a variety of Gerrit
+  configuration options. To learn more about these configuration options, see
+  link:config-gerrit.html[Configuration].
+* `--dev`. This parameter configures the server to use the authentication
+  option, `DEVELOPMENT_BECOME_ANY_ACCOUNT`. This authentication type makes it
+  easy for you to switch between different users to explore how Gerrit works.
+  To learn more about setting up Gerrit for development, see
+  link:dev-readme.html[Developer Setup].
+
+This command displays a number of messages in the terminal window. The following
+is an example of these messages:
+
+....
+Generating SSH host key ... rsa(simple)... done
+Initialized /home/gerrit/gerrit_testsite
+Executing /home/gerrit/gerrit_testsite/bin/gerrit.sh start
+Starting Gerrit Code Review: OK
+....
+
+The last message you should see is `Starting Gerrit Code Review: OK`. This
+message informs you that the Gerrit service is now running.
+
+== Update the listen URL
+
+Another recommended task is to change the URL that Gerrit listens to from `*`
+to `localhost`. This change helps prevent outside connections from contacting
+the instance.
+
+....
+git config --file ~/gerrit_testsite/etc/gerrit.config httpd.listenUrl 'http://localhost:8080'
+....
+
+== Restart the Gerrit service
+
+You must restart the Gerrit service for your authentication type and listen URL
+changes to take effect.
+
+....
+~/gerrit_testsite/bin/gerrit.sh restart
+....
+
+== Viewing Gerrit
+
+At this point, you have a basic installation of Gerrit. You can view this
+installation by opening a browser and entering the following URL:
+
+....
+http://localhost:8080
+....
+
+== Next steps
+
+Through this quickstart, you now have a simple version of Gerrit running on your
+Linux machine. You can use this installation to explore the UI and become
+familiar with some of Gerrit's features. For a more detailed installation guide,
+see link:install.html[Standalone Daemon Installation Guide].
+
+GERRIT
+------
+
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index eae33c2..5239730 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -86,6 +86,12 @@
 
 * `sql/connection_pool/connections`: SQL database connections.
 
+=== Topics
+
+* `topic/cross_project_submit`: number of cross-project topic submissions.
+* `topic/cross_project_submit_completed`: number of cross-project
+topic submissions that concluded successfully.
+
 === JGit
 
 * `jgit/block_cache/cache_used`: Bytes of memory retained in JGit block cache.
@@ -103,6 +109,10 @@
 
 * `batch_update/execute_change_ops`: BatchUpdate change update latency,
 excluding reindexing
+* `batch_update/retry_attempt_counts`: Distribution of number of attempts made
+by RetryHelper (1 == single attempt, no retry)
+* `batch_update/retry_timeout_count`: Number of executions of RetryHelper that
+ultimately timed out
 
 === NoteDb
 
@@ -113,6 +123,9 @@
 * `notedb/auto_rebuild_latency`: NoteDb auto-rebuilding latency by table.
 * `notedb/auto_rebuild_failure_count`: NoteDb auto-rebuilding attempts that
 failed by table.
+* `notedb/external_id_update_count`: Total number of external ID updates.
+* `notedb/read_all_external_ids_latency`: Latency for reading all
+external ID's from NoteDb.
 
 === Reviewer Suggestion
 
@@ -125,6 +138,10 @@
 * `reviewer_suggestion/query_groups`: Latency for querying groups for reviewer
 suggestion.
 
+=== Repo Sequences
+
+* `sequence/next_id_latency`: Latency of requesting IDs from repo sequences.
+
 === Replication Plugin
 
 * `plugins/replication/replication_latency`: Time spent pushing to remote
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
new file mode 100644
index 0000000..f72ffc7
--- /dev/null
+++ b/Documentation/note-db.txt
@@ -0,0 +1,193 @@
+= Gerrit Code Review - NoteDb Backend
+
+NoteDb is the next generation of Gerrit storage backend, which replaces the
+traditional SQL backend for change and account metadata with storing data in the
+same repository as code changes.
+
+.Advantages
+- *Simplicity*: All data is stored in one location in the site directory, rather
+  than being split between the site directory and a possibly external database
+  server.
+- *Consistency*: Replication and backups can use a snapshot of the Git
+  repository refs, which will include both the branch and patch set refs, and
+  the change metadata that points to them.
+- *Auditability*: Rather than storing mutable rows in a database, modifications
+  to changes are stored as a sequence of Git commits, automatically preserving
+  history of the metadata. +
+  There are no strict guarantees, and meta refs may be rewritten, but the
+  default assumption is that all operations are logged.
+- *Extensibility*: Plugin developers can add new fields to metadata without the
+  core database schema having to know about them.
+- *New features*: Enables simple federation between Gerrit servers, as well as
+  offline code review and interoperation with other tools.
+
+== Current Status
+
+- Storing change metadata is fully implemented in the 2.15 release, and is the
+  default for new sites.
+- Admins may use an link:#offline-migration[offline] or
+  link:#online-migration[online] tool to migrate change data in an existing
+  site from ReviewDb.
+- Storing link:config-accounts.html[account data] is fully implemented in the
+  2.15 release. Account data is migrated automatically during the upgrade
+  process by running `gerrit.war init`.
+- Account and change metadata on the servers behind `googlesource.com` is fully
+  migrated to NoteDb. In other words, if you use
+  link:https://gerrit-review.googlesource.com/[gerrit-review], you're already
+  using NoteDb.
+
+For an example NoteDb change, poke around at this one:
+----
+  git fetch https://gerrit.googlesource.com/gerrit refs/changes/70/98070/meta \
+      && git log -p FETCH_HEAD
+----
+
+== Future Work ("Gerrit 3.0")
+
+- Storing group data is a work in progress. Like account data, it will be
+  migrated automatically.
+- NoteDb will be the only database format supported by Gerrit 3.0. The offline
+  change data migration tool will be included in Gerrit 3.0, but online
+  migration will only be available in the 2.x line.
+
+[[migration]]
+== Migration
+
+Migrating change metadata can take a long time for large sites, so
+administrators choose whether to do the migration offline or online, depending
+on their available resources and tolerance for downtime.
+
+Only change metadata requires manual steps to migrate it from ReviewDb; account
+and group data is migrated automatically by `gerrit.war init`.
+
+[[online-migration]]
+=== Online
+
+To start the online migration, set the `noteDb.changes.autoMigrate` option in
+`gerrit.config` and restart Gerrit:
+----
+[noteDb "changes"]
+  autoMigrate = true
+----
+
+Alternatively, pass the `--migrate-to-note-db` flag to
+`gerrit.war daemon`:
+----
+  java -jar gerrit.war daemon -d /path/to/site --migrate-to-note-db
+----
+
+Both ways of starting the online migration are equivalent. Once started, it is
+safe to restart the server at any time; the migration will pick up where it left
+off. Migration progress will be reported to the Gerrit logs.
+
+*Advantages*
+
+* No downtime required.
+
+*Disadvantages*
+
+* Only available in 2.x; will not be available in Gerrit 3.0.
+* Much slower than offline; uses only a single thread, to leave resources
+  available for serving traffic.
+* Performance may be degraded, particularly of updates; data needs to be written
+  to both ReviewDb and NoteDb while the migration is in progress.
+
+[[offline-migration]]
+=== Offline
+
+To run the offline migration, run the `migrate-to-note-db` program:
+----
+  java -jar gerrit.war migrate-to-note-db -d /path/to/site
+----
+
+Once started, it is safe to cancel and restart the migration process, or to
+switch to the online process.
+
+[NOTE]
+Migration requires a heap size comparable to running a Gerrit server. If you
+normally run `gerrit.war daemon` with an `-Xmx` flag, pass that to the migration
+tool as well.
+
+*Advantages*
+
+* Much faster than online; can use all available CPUs, since no live traffic
+  needs to be served.
+* No degraded performance of live servers due to writing data to 2 locations.
+* Available in both Gerrit 2.x and 3.0.
+
+*Disadvantages*
+
+* May require substantial downtime; takes about twice as long as an
+  link:pgm-reindex.html[offline reindex]. (In fact, one of the migration steps
+  is a full reindex, so it can't possibly take less time.)
+
+[[trial-migration]]
+==== Trial mode
+
+The migration tool also supports "trial mode", where changes are
+migrated to NoteDb and read from NoteDb at runtime, but their primary storage
+location is still ReviewDb, and data is kept in sync between the two locations.
+
+To run the migration in trial mode, add `--trial` to `migrate-to-note-db` or
+`daemon`:
+----
+  java -jar gerrit.war migrate-to-note-db --trial -d /path/to/site
+  # OR
+  java -jar gerrit.war daemon -d /path/to/site --migrate-to-note-db --trial
+----
+
+Or, set `noteDb.changes.trial=true` in `gerrit.config`.
+
+There are several use cases for trial mode:
+
+* Help test early releases of the migration tool for bugs with lower risk.
+* Try out new NoteDb-only features like
+  link:rest-api-changes.txt#get-hashtags[hashtags] without running the full
+  migration.
+
+To continue with the full migration after running the trial migration, use
+either the online or offline migration steps as normal. To revert to
+ReviewDb-only, remove `noteDb.changes.read` and `noteDb.changes.write` from
+`notedb.config` and restart Gerrit.
+
+== Configuration
+
+The migration process works by setting a configuration option in `notedb.config`
+for each step in the process, then performing the corresponding data migration.
+
+Config options are read from `notedb.config` first, falling back to
+`gerrit.config`. If editing config manually, you may edit either file, but the
+migration process itself only touches `notedb.config`. This means if your
+`gerrit.config` is managed with Puppet or a similar tool, it can overwrite
+`gerrit.config` without affecting the migration process. You should not manage
+`notedb.config` with Puppet, but you may copy values back into `gerrit.config`
+and delete `notedb.config` at some later point after completing the migration.
+
+In general, users should not set the options described below manually; this
+section serves primarily as a reference.
+
+- `noteDb.changes.write=true`: During a ReviewDb write, the state of the change
+  in NoteDb is written to the `note_db_state` field in the `Change` entity.
+  After the ReviewDb write, this state is written into NoteDb, resulting in
+  effectively double the time for write operations. NoteDb write errors are
+  dropped on the floor, and no attempt is made to read from ReviewDb or correct
+  errors (without additional configuration, below).
+- `noteDb.changes.read=true`: Change data is written
+  to and read from NoteDb, but ReviewDb is still the source of truth. During
+  reads, first read the change from ReviewDb, and compare its `note_db_state`
+  with what is in NoteDb. If it doesn't match, immediately "auto-rebuild" the
+  change, copying data from ReviewDb to NoteDb and returning the result.
+- `noteDb.changes.primaryStorage=NOTE_DB`: New changes are written only to
+  NoteDb, but changes whose primary storage is ReviewDb are still supported.
+  Continues to read from ReviewDb first as in the previous stage, but if the
+  change is not in ReviewDb, falls back to reading from NoteDb. +
+  Migration of existing changes is described in the link:#migration[Migration]
+  section above. +
+  Due to an implementation detail, writes to Changes or related tables still
+  result in write calls to the database layer, but they are inside a transaction
+  that is always rolled back.
+- `noteDb.changes.disableReviewDb=true`: All access to Changes or related tables
+  is disabled; reads return no results, and writes are no-ops. Assumes the state
+  of all changes in NoteDb is accurate, and so is only safe once all changes are
+  NoteDb primary. Otherwise, reading changes only from NoteDb might result in
+  inaccurate results, and writing to NoteDb would compound the problem. +
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
new file mode 100644
index 0000000..0064794
--- /dev/null
+++ b/Documentation/pg-plugin-dev.txt
@@ -0,0 +1,251 @@
+= Gerrit Code Review - PolyGerrit Plugin Development
+
+CAUTION: Work in progress. Hard hat area. Please
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
+feedback] if something's not right.
+
+For migrating existing GWT UI plugins, please check out the
+link:pg-plugin-migration.html#migration[migration guide].
+
+[[loading]]
+== Plugin loading and initialization
+
+link:js-api.html#_entry_point[Entry point] for the plugin and the loading method
+is based on link:http://w3c.github.io/webcomponents/spec/imports/[HTML Imports]
+spec.
+
+* The plugin provides pluginname.html, and can be a standalone file or a static
+  asset in a jar as a link:dev-plugins.html#deployment[Web UI plugin].
+* pluginname.html contains a `dom-module` tag with a script that uses
+  `Gerrit.install()`. There should only be single `Gerrit.install()` per file.
+* PolyGerrit imports pluginname.html along with all required resources defined in it
+  (fonts, styles, etc).
+* For standalone plugins, the entry point file is a `pluginname.html` file
+  located in `gerrit-site/plugins` folder, where `pluginname` is an alphanumeric
+  plugin name.
+
+Note: Code examples target modern brosers (Chrome, Firefox, Safari, Edge)
+
+Here's a recommended starter `myplugin.html`:
+
+``` html
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(plugin => {
+      'use strict';
+
+      // Your code here.
+    });
+  </script>
+</dom-module>
+```
+
+[[low-level-api-concepts]]
+== Low-level DOM API concepts
+
+Basically, the DOM is the API surface. Low-level API provides methods for
+decorating, replacing, and styling DOM elements exposed through a set of
+endpoints.
+
+PolyGerrit provides a simple way for accessing the DOM via DOM hooks API. A DOM
+hook is a custom element that is instantiated for the plugin endpoint. In the
+decoration case, a hook is set with a `content` attribute that points to the DOM
+element.
+
+1. Get the DOM hook API instance via `plugin.hook(endpointName)`
+2. Set up an `onAttached` callback
+3. Callback is called when the hook element is created and inserted into DOM
+4. Use element.content to get UI element
+
+``` js
+Gerrit.install(plugin => {
+  const domHook = plugin.hook('reply-text');
+  domHook.onAttached(element => {
+    if (!element.content) { return; }
+    // element.content is a reply dialog text area.
+  });
+});
+```
+
+[[low-level-decorating]]
+=== Decorating DOM Elements
+
+For each endpoint, PolyGerrit provides a list of DOM properties (such as
+attributes and events) that are supported in the long-term.
+
+NOTE: TODO: Insert link to the full endpoints API.
+
+``` js
+Gerrit.install(plugin => {
+  const domHook = plugin.hook('reply-text');
+  domHook.onAttached(element => {
+    if (!element.content) { return; }
+    element.content.style.border = '1px red dashed';
+  });
+});
+```
+
+[[low-level-replacing]]
+=== Replacing DOM Elements
+
+An endpoint's contents can be replaced by passing the replace attribute as an
+option.
+
+``` js
+Gerrit.install(plugin => {
+  const domHook = plugin.hook('header-title', {replace: true});
+  domHook.onAttached(element => {
+    element.appendChild(document.createElement('my-site-header'));
+  });
+});
+```
+
+[[low-level-style]]
+=== Styling DOM Elements
+
+A plugin may provide Polymer's
+https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[style
+modules] to style individual endpoints using
+`plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
+as a standalone `<dom-module>` defined in the same .html file.
+
+Note: TODO: Insert link to the full styling API.
+
+``` html
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(plugin => {
+      plugin.registerStyleModule('change-metadata', 'some-style-module');
+    });
+  </script>
+</dom-module>
+
+<dom-module id="some-style-module">
+  <style>
+    html {
+      --change-metadata-label-status: {
+        display: none;
+      }
+      --change-metadata-strategy: {
+        display: none;
+      }
+    }
+  </style>
+</dom-module>
+```
+
+[[high-level-api-concepts]]
+== High-level DOM API concepts
+
+High leve API is based on low-level DOM API and is essentially a standartized
+way for doing common tasks. It's less flexible, but will be a bit more stable.
+
+Common way to access high-leve API is through `plugin` instance passed into
+setup callback parameter of `Gerrit.install()`, also sometimes referred as
+`self`.
+
+[[low-level-api]]
+== Low-level DOM API
+
+Low-level DOM API methods are the base of all UI customization.
+
+=== attributeHelper
+`plugin.attributeHelper(element)`
+
+Note: TODO
+
+=== eventHelper
+`plugin.eventHelper(element)`
+
+Note: TODO
+
+=== hook
+`plugin.hook(endpointName, opt_options)`
+
+Note: TODO
+
+=== registerCustomComponent
+`plugin.registerCustomComponent(endpointName, opt_moduleName, opt_options)`
+
+Note: TODO
+
+=== registerStyleModule
+`plugin.registerStyleModule(endpointName, moduleName)`
+
+Note: TODO
+
+[[high-level-api]]
+== High-level API
+
+Plugin instance provides access to number of more specific APIs and methods
+to be used by plugin authors.
+
+=== changeReply
+`plugin.changeReply()`
+
+Note: TODO
+
+=== changeView
+`plugin.changeView()`
+
+Note: TODO
+
+=== delete
+`plugin.delete(url, opt_callback)`
+
+Note: TODO
+
+=== get
+`plugin.get(url, opt_callback)`
+
+Note: TODO
+
+=== getPluginName
+`plugin.getPluginName()`
+
+Note: TODO
+
+=== getServerInfo
+`plugin.getServerInfo()`
+
+Note: TODO
+
+=== on
+`plugin.on(eventName, callback)`
+
+Note: TODO
+
+=== popup
+`plugin.popup(moduleName)`
+
+Note: TODO
+
+=== post
+`plugin.post(url, payload, opt_callback)`
+
+Note: TODO
+
+[plugin-project]
+=== project
+`plugin.project()`
+
+.Params:
+- none
+
+.Returns:
+- Instance of link:pg-plugin-project-api.html[GrProjectApi].
+
+=== put
+`plugin.put(url, payload, opt_callback)`
+
+Note: TODO
+
+=== theme
+`plugin.theme()`
+
+Note: TODO
+
+=== url
+`plugin.url(opt_path)`
+
+Note: TODO
diff --git a/Documentation/pg-plugin-migration.txt b/Documentation/pg-plugin-migration.txt
new file mode 100644
index 0000000..cb3340a
--- /dev/null
+++ b/Documentation/pg-plugin-migration.txt
@@ -0,0 +1,151 @@
+= Gerrit Code Review - PolyGerrit Plugin Development
+
+CAUTION: Work in progress. Hard hat area. Please
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
+feedback] if something's not right.
+
+[[migration]]
+== Incremental migration of existing GWT UI plugins
+
+link:pg-plugin-dev.html[PolyGerrit plugin API] is based on different concepts and
+provides a different type of API compared to the one available to GWT
+plugins. Depending on the plugin, it might require significant modifications to
+existing UI scripts to fully take advantage of the benefits provided by the PolyGerrit API.
+
+To make migration easier, PolyGerrit recommends an incremental migration
+strategy. Starting with a .js file that works for GWT UI, plugin author can
+incrementally migrate deprecated APIs to the new plugin API.
+
+The goal for this guide is to provide a migration path from .js-based UI script to
+a html based implementation
+
+NOTE: Web UI plugins distributed as a single .js file are not covered in this
+guide.
+
+Let's start with a basic plugin that has an UI module. Commonly, file tree
+should look like this:
+
+  ├── BUILD
+  ├── LICENSE
+  └── src
+      └── main
+          ├── java
+          │   └── com
+          │       └── foo
+          │           └── SamplePluginModule.java
+          └── resources
+              └── static
+                  └── sampleplugin.js
+
+For simplicity's sake, let's assume SamplePluginModule.java has following
+content:
+
+``` java
+public class SamplePluginModule extends AbstractModule {
+
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new JavaScriptPlugin("sampleplugin.js"));
+  }
+}
+```
+
+=== Step 1: Create `sampleplugin.html`
+
+As a first step, create `sampleplugin.html` and include the UI script in the
+module file.
+
+NOTE: GWT UI ignores html files which it doesn't support.
+
+``` java
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new JavaScriptPlugin("sampleplugin.js"));
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new JavaScriptPlugin("sampleplugin.html"));
+  }
+```
+
+Here's recommended starter code for `sampleplugin.html`:
+
+NOTE: By specification, the `id` attribute of `dom-module` *must* contain a dash
+(-).
+
+``` html
+<dom-module id="sample-plugin">
+  <script>
+    Gerrit.install(plugin => {
+        // Setup block, is executed before sampleplugin.js
+
+        // Install deprecated JS APIs (onAction, popup, etc)
+        plugin.deprecated.install();
+    });
+  </script>
+
+  <script src="./sampleplugin.js"></script>
+
+  <script>
+    Gerrit.install(plugin => {
+        // Cleanup block, is executed after sampleplugin.js
+    });
+  </script>
+</dom-module>
+```
+
+Here's how this works:
+
+- PolyGerrit detects migration scenario because UI scripts have same filename
+and different extensions
+ * PolyGerrit will load `sampleplugin.html` and skip `sampleplugin.js`
+ * PolyGerrit will reuse `plugin` (aka `self`) instance for `Gerrit.install()`
+callbacks
+- `sampleplugin.js` is loaded since it's referenced in `sampleplugin.html`
+- setup script tag code is executed before `sampleplugin.js`
+- cleanup script tag code is executed after `sampleplugin.js`
+- `plugin.deprecated.install()` enables deprecated APIs (onAction(), popup(),
+etc) before `sampleplugin.js` is loaded
+
+This means the plugin instance is shared between .html-based and .js-based
+code. This allows to gradually and incrementally transfer code to the new API.
+
+=== Step 2: Create cut-off marker in `sampleplugin.js`
+
+Commonly, window.Polymer is being used to detect in GWT UI script if it's being
+executed inside PolyGerrit. This could be used to separate code that was already
+migrated to new APIs from old not yet migrated code.
+
+During incremental migration, some of the UI code will be reimplemented using
+the PolyGerrit plugin API. However, old code still could be required for the plugin
+to work in GWT UI.
+
+To handle this case, add the following code at the end of the installation
+callback in `sampleplugin.js`
+
+``` js
+Gerrit.install(function(self) {
+
+  // Existing code here, not modified.
+
+  if (window.Polymer) { return; } // Cut-off marker
+
+  // Everything below was migrated to PolyGerrit plugin API.
+  // Code below is still needed for the plugin to work in GWT UI.
+});
+```
+
+=== Step 3: Migrate!
+
+The code that uses deprecated APIs should be eventually rewritten using
+non-deprecated counterparts. Duplicated pieces could be kept under cut-off
+marker to work in GWT UI.
+
+If some data or functions needs to be shared between code in .html and .js, it
+could be stored in the `plugin` (aka `self`) object that's shared between both
+
+=== Step 4: Cleanup
+
+Once deprecated APIs are migrated, `sampleplugin.js` will only contain
+duplicated code that's required for GWT UI to work. As soon as GWT support is removed from Gerrit
+that file can be simply deleted, along with the script tag loading it.
diff --git a/Documentation/pg-plugin-project-api.txt b/Documentation/pg-plugin-project-api.txt
new file mode 100644
index 0000000..897430c
--- /dev/null
+++ b/Documentation/pg-plugin-project-api.txt
@@ -0,0 +1,36 @@
+= Gerrit Code Review - Project admin customization API
+
+This API is provided by link:pg-plugin-dev.html#plugin-project[plugin.project()]
+and provides customization to admin page.
+
+== createCommand
+`projectApi.createCommand(title, checkVisibleCallback)`
+
+Create a project command in the admin panel.
+
+.Params
+- *title* String title.
+- *checkVisibleCallback* function to configure command visibility.
+
+.Returns
+- GrProjectApi for chainging.
+
+`checkVisibleCallback(projectName, projectConfig)`
+
+.Params
+- *projectName* String project name.
+- *projectConfig* Object REST API response for project config.
+
+.Returns
+- `false` to hide the command for the specific project.
+
+== onTap
+`projectApi.onTap(tapCalback)`
+
+Add a command tap callback.
+
+.Params
+- *tapCallback* function that's excuted on command tap.
+
+.Returns
+- Nothing
diff --git a/Documentation/pg-plugin-styling.txt b/Documentation/pg-plugin-styling.txt
new file mode 100644
index 0000000..58b6d7a
--- /dev/null
+++ b/Documentation/pg-plugin-styling.txt
@@ -0,0 +1,69 @@
+= Gerrit Code Review - PolyGerrit Plugin Styling
+
+== Plugin styles
+
+Plugins may provide
+link:https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[Polymer
+style modules] for UI CSS-based customization.
+
+PolyGerrit UI implements number of styling endpoints, which apply CSS mixins
+link:https://tabatkins.github.io/specs/css-apply-rule/[using @apply] to its
+direct contents.
+
+NOTE: Only items (ie CSS properties and mixin targets) documented here are
+guaranteed to work in the long term, since they are covered by integration
+tests. + When there is a need to add new property or endpoint, please
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue[file
+a bug] stating your usecase to track and maintain for future releases.
+
+Plugin should be html-based and imported following PolyGerrit's
+link:pg-plugin-dev.html#loading[dev guide].
+
+Plugin should provide Style Module, for example:
+
+``` html
+  <dom-module id="some-style">
+    <style>
+      :root {
+        --css-mixin-name: {
+          property: value;
+        }
+      }
+    </style>
+  </dom-module>
+```
+
+Plugin should register style module with a styling endpoint using
+`Plugin.prototype.registerStyleModule(endpointName, styleModuleName)`, for
+example:
+
+``` js
+  Gerrit.install(function(plugin) {
+    plugin.registerStyleModule('some-endpoint', 'some-style');
+  });
+```
+
+== Available styling endpoints
+=== change-metadata
+Following custom css mixins are recognized:
+
+* `--change-metadata-assignee`
++
+is applied to `gr-change-metadata section.assignee`
+* `--change-metadata-label-status`
++
+is applied to `gr-change-metadata section.labelStatus`
+* `--change-metadata-strategy`
++
+is applied to `gr-change-metadata section.strategy`
+* `--change-metadata-topic`
++
+is applied to `gr-change-metadata section.topic`
+
+Following CSS properties have
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html[long-term
+support via integration test]:
+
+* `display`
++
+can be set to `none` to hide a section.
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index 76a26e1..0b1a3e5 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -94,8 +94,6 @@
 ----
 [cache "accounts"]
   maxAge = 5 min
-[cache "accounts_byemail"]
-  maxAge = 5 min
 [cache "diff"]
   maxAge = 5 min
 [cache "groups"]
diff --git a/Documentation/pgm-prolog-shell.txt b/Documentation/pgm-prolog-shell.txt
index aee5799..a669aa7 100644
--- a/Documentation/pgm-prolog-shell.txt
+++ b/Documentation/pgm-prolog-shell.txt
@@ -36,8 +36,8 @@
 	         Copyright(C) 1997-2009 M.Banbara and N.Tamura
 	(type Ctrl-D or "halt." to exit, "['path/to/file.pl']." to load a file)
 
-	{consulting /usr/local/google/users/sop/gerrit2/gerrit/simple.pl ...}
-	{/usr/local/google/users/sop/gerrit2/gerrit/simple.pl consulted 99 msec}
+	{consulting /usr/local/google/users/sop/gerrit/gerrit/simple.pl ...}
+	{/usr/local/google/users/sop/gerrit/gerrit/simple.pl consulted 99 msec}
 
 	| ?- food(Type).
 
diff --git a/Documentation/project-configuration.txt b/Documentation/project-configuration.txt
index 7d93c64..539ec27 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -60,8 +60,9 @@
 [[fast_forward_only]]
 * Fast Forward Only
 +
-With this method no merge commits are produced. All merges must
-be handled on the client, prior to uploading to Gerrit for review.
+With this method Gerrit does not create merge commits on submitting a
+change. Merge commits may still be submitted, but they must be created
+on the client prior to uploading to Gerrit for review.
 +
 To submit a change, the change must be a strict superset of the
 destination branch.  That is, the change must already contain the
@@ -70,7 +71,8 @@
 [[merge_if_necessary]]
 * Merge If Necessary
 +
-This is the default for a new project.
+This is the default for new projects, unless overridden by a global
+link:config-gerrit.html#repository.name.defaultSubmitType[`defaultSubmitType` option].
 +
 If the change being submitted is a strict superset of the destination
 branch, then the branch is fast-forwarded to the change.  If not,
@@ -113,7 +115,7 @@
 branch, then the branch is fast-forwarded to the change.  If not,
 then the change is automatically rebased and then the branch is
 fast-forwarded to the change.
-
++
 When Gerrit tries to do a merge, by default the merge will only
 succeed if there is no path conflict.  A path conflict occurs when
 the same file has also been changed on the other side of the merge.
@@ -125,11 +127,13 @@
 if fast forward is possible AND like Cherry Pick it ensures footers such as
 Change-Id, Reviewed-On, and others are present in resulting commit that is
 merged.
-
++
 Thus, Rebase Always can be considered similar to Cherry Pick, but with
 the important distinction that Rebase Always does not ignore dependencies.
 
 [[content_merge]]
+=== Allow content merges
+
 If `Allow content merges` is enabled, Gerrit will try
 to do a content merge when a path conflict occurs.
 
@@ -224,6 +228,7 @@
 The defined maximum Git object size limit is inherited by any child
 project.
 
+[[require-signed-off-by]]
 === Require Signed-off-by
 
 The `Require Signed-off-by in commit message` option defines whether a
diff --git a/Documentation/prolog-change-facts.txt b/Documentation/prolog-change-facts.txt
index 11d17b8..01a1878 100644
--- a/Documentation/prolog-change-facts.txt
+++ b/Documentation/prolog-change-facts.txt
@@ -58,6 +58,10 @@
                       |`current_user(user(peer_daemon)).`
                       |`current_user(user(replication)).`
 
+|`pure_revert/1`     |`pure_revert(1).`
+    |link:rest-api-changes.html#get-pure-revert[Pure revert] as integer atom (1 if
+        the change is a pure revert, 0 otherwise)
+
 |`uploader/1`     |`uploader(user(1000000)).`
     |Uploader as `user(ID)` term. ID is the numeric account ID
 
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 78497eb..ad4530e 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -1052,6 +1052,56 @@
 indicate to the user that all the comments have to be resolved for the
 change to become submittable.
 
+=== Example 17: Make change submittable if it is a pure revert
+In this example we will use the `pure_revert` fact about a
+change. Our goal is to block the submission of any change that is not a
+pure revert. Basically, it can be achieved by the following rules:
+
+`rules.pl`
+[source,prolog]
+----
+submit_rule(submit(R)) :-
+    gerrit:pure_revert(1),
+    !,
+    gerrit:commit_author(A),
+    R = label('Is-Pure-Revert', ok(A)).
+
+submit_rule(submit(R)) :-
+    gerrit:pure_revert(U),
+    U /= 1,
+    R = label('Is-Pure-Revert', need(_)).
+----
+
+Suppose currently a change is submittable if it gets `+2` for `Code-Review`
+and `+1` for `Verified`. It can be extended to support the above rules as
+follows:
+
+`rules.pl`
+[source,prolog]
+----
+submit_rule(submit(CR, V, R)) :-
+    base(CR, V),
+    gerrit:pure_revert(1),
+    !,
+    gerrit:commit_author(A),
+    R = label('Is-Pure-Revert', ok(A)).
+
+submit_rule(submit(CR, V, R)) :-
+    base(CR, V),
+    gerrit:pure_revert(U),
+    U /= 1,
+    R = label('Is-Pure-Revert', need(_)).
+
+base(CR, V) :-
+    gerrit:max_with_block(-2, 2, 'Code-Review', CR),
+    gerrit:max_with_block(-1, 1, 'Verified', V).
+----
+
+Note that a new label as `Is-Pure-Revert` should not be configured.
+It's only used to show `'Needs Is-Pure-Revert'` in the UI to clearly
+indicate to the user that the change has to be a pure revert in order
+to become submittable.
+
 == Examples - Submit Type
 The following examples show how to implement own submit type rules.
 
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index a90ea1a..6f49a7d 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -264,7 +264,41 @@
       "can_upload": true,
       "can_add": true,
       "can_add_tags": true,
-      "config_visible": true
+      "config_visible": true,
+      "groups": {
+         "53a4f647a89ea57992571187d8025f830625192a": {
+           "url": "#/admin/groups/uuid-53a4f647a89ea57992571187d8025f830625192a",
+           "options": {},
+           "description": "Gerrit Site Administrators",
+           "group_id": 1,
+           "owner": "Administrators",
+           "owner_id": "53a4f647a89ea57992571187d8025f830625192a",
+           "created_on": "2009-06-08 23:31:00.000000000",
+           "name": "Administrators"
+         },
+         "global:Registered-Users": {
+           "options": {},
+           "name": "Registered Users"
+         },
+         "global:Project-Owners": {
+           "options": {},
+           "name": "Project Owners"
+         },
+         "15bfcd8a6de1a69c50b30cedcdcc951c15703152": {
+           "url": "#/admin/groups/uuid-15bfcd8a6de1a69c50b30cedcdcc951c15703152",
+           "options": {},
+           "description": "Users who perform batch actions on Gerrit",
+           "group_id": 2,
+           "owner": "Administrators",
+           "owner_id": "53a4f647a89ea57992571187d8025f830625192a",
+           "created_on": "2009-06-08 23:31:00.000000000",
+           "name": "Non-Interactive Users"
+         },
+         "global:Anonymous-Users": {
+           "options": {},
+           "name": "Anonymous Users"
+         }
+      }
     },
     "MyProject": {
       "revision": "61157ed63e14d261b6dca40650472a9b0bd88474",
@@ -372,6 +406,10 @@
 |`config_visible`     |not set if `false`|
 Whether the calling user can see the `refs/meta/config` branch of the
 project.
+|`groups`            |A map of group UUID to
+link:rest-api-groups.html#group-info[GroupInfo] objects, describing
+the group UUIDs used in the `local` map. Groups that are not visible
+are omitted from the `groups` map.
 |==================================
 
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 4409d1f..590b534 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -412,7 +412,10 @@
 .Response
 ----
   HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
 
+  )]}'
   ok
 ----
 
@@ -470,6 +473,8 @@
 in the request body inside a link:#http-password-input[
 HttpPasswordInput] entity.
 
+The account must have a username.
+
 .Request
 ----
   PUT /accounts/self/password.http HTTP/1.0
@@ -1248,6 +1253,8 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "ABBREV",
     "mute_common_path_prefixes": true,
+    "publish_comments_on_push": true,
+    "work_in_progress_by_default": true,
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1255,10 +1262,6 @@
         "name": "Changes"
       },
       {
-        "url": "#/q/owner:self+is:draft",
-        "name": "Drafts"
-      },
-      {
         "url": "#/q/has:draft",
         "name": "Draft Comments"
       },
@@ -1313,10 +1316,6 @@
         "name": "Changes"
       },
       {
-        "url": "#/q/owner:self+is:draft",
-        "name": "Drafts"
-      },
-      {
         "url": "#/q/has:draft",
         "name": "Draft Comments"
       },
@@ -1361,6 +1360,8 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "NAME",
     "diff_view": "SIDE_BY_SIDE",
+    "publish_comments_on_push": true,
+    "work_in_progress_by_default": true,
     "mute_common_path_prefixes": true,
     "my": [
       {
@@ -1368,10 +1369,6 @@
         "name": "Changes"
       },
       {
-        "url": "#/q/owner:self+is:draft",
-        "name": "Drafts"
-      },
-      {
         "url": "#/q/has:draft",
         "name": "Draft Comments"
       },
@@ -1759,9 +1756,9 @@
   POST /a/accounts/self/external.ids:delete HTTP/1.0
   Content-Type: application/json;charset=UTF-8
 
-  {
+  [
     "mailto:john.doe@example.com"
-  }
+  ]
 ----
 
 .Response
@@ -2126,6 +2123,8 @@
 |`registered_on`     ||
 The link:rest-api.html#timestamp[timestamp] of when the account was
 registered.
+|`inactive`          |not set if `false`|
+Whether the account is inactive.
 |=================================
 
 [[account-external-id-info]]
@@ -2650,6 +2649,12 @@
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
 Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
+|`publish_comments_on_push`     |not set if `false`|
+Whether to link:user-upload.html#publish-comments[publish draft comments] on
+push by default.
+|`work_in_progress_by_default`  |not set if `false`|
+Whether to link:user-upload.html#wip[set work-in-progress] on
+push or on create changes online by default.
 |============================================
 
 [[preferences-input]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 9880d54..4434c2e 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -26,7 +26,7 @@
     "subject" : "Let's support 100% Gerrit workflow direct in browser",
     "branch" : "master",
     "topic" : "create-change-in-browser",
-    "status" : "DRAFT"
+    "status" : "NEW"
   }
 ----
 
@@ -47,7 +47,7 @@
     "topic": "create-change-in-browser",
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
     "subject": "Let's support 100% Gerrit workflow direct in browser",
-    "status": "DRAFT",
+    "status": "NEW",
     "created": "2014-05-05 07:15:44.639000000",
     "updated": "2014-05-05 07:15:44.639000000",
     "mergeable": true,
@@ -334,6 +334,12 @@
   server.
 --
 
+[[tracking-ids]]
+--
+* `TRACKING_IDS`: include references to external tracking systems
+  as link:#tracking-id-info[TrackingIdInfo].
+--
+
 .Request
 ----
   GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES&o=DOWNLOAD_COMMANDS HTTP/1.0
@@ -517,64 +523,6 @@
   }
 ----
 
-[[create-merge-patch-set-for-change]]
-=== Create Merge Patch Set For Change
---
-'POST /changes/link:#change-id[\{change-id\}]/merge'
---
-
-Update an existing change by using a
-link:#merge-patch-set-input[MergePatchSetInput] entity.
-
-Gerrit will create a merge commit based on the information of
-MergePatchSetInput and add a new patch set to the change corresponding
-to the new merge commit.
-
-.Request
-----
-  POST /changes/test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc/merge  HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "subject": "Merge dev_branch into master",
-    "merge": {
-      "source": "refs/changes/34/1234/1"
-    }
-  }
-----
-
-As response a link:#change-info[ChangeInfo] entity with current revision is
-returned that describes the resulting change.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {
-    "id": "test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc",
-    "project": "test",
-    "branch": "master",
-    "hashtags": [],
-    "change_id": "Ic5466d107c5294414710935a8ef3b0180fb848dc",
-    "subject": "Merge dev_branch into master",
-    "status": "NEW",
-    "created": "2016-09-23 18:08:53.238000000",
-    "updated": "2016-09-23 18:09:25.934000000",
-    "submit_type": "MERGE_IF_NECESSARY",
-    "mergeable": true,
-    "insertions": 5,
-    "deletions": 0,
-    "_number": 72,
-    "owner": {
-      "_account_id": 1000000
-    },
-    "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822"
-  }
-----
-
 [[get-change-detail]]
 === Get Change Detail
 --
@@ -786,9 +734,9 @@
           "email": "john.doe@example.com",
           "username": "jdoe"
         },
-        "updated": "2013-03-23 21:34:02.419000000",
+        "date": "2013-03-23 21:34:02.419000000",
         "message": "Patch Set 1:\n\nThis is the first message.",
-        "revision_number": 1
+        "_revision_number": 1
       },
       {
         "id": "WEEdhU",
@@ -798,14 +746,105 @@
           "email": "jane.roe@example.com",
           "username": "jroe"
         },
-        "updated": "2013-03-23 21:36:52.332000000",
+        "date": "2013-03-23 21:36:52.332000000",
         "message": "Patch Set 1:\n\nThis is the second message.\n\nWith a line break.",
-        "revision_number": 1
+        "_revision_number": 1
       }
     ]
   }
 ----
 
+[[create-merge-patch-set-for-change]]
+=== Create Merge Patch Set For Change
+--
+'POST /changes/link:#change-id[\{change-id\}]/merge'
+--
+
+Update an existing change by using a
+link:#merge-patch-set-input[MergePatchSetInput] entity.
+
+Gerrit will create a merge commit based on the information of
+MergePatchSetInput and add a new patch set to the change corresponding
+to the new merge commit.
+
+.Request
+----
+  POST /changes/test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc/merge  HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "subject": "Merge dev_branch into master",
+    "merge": {
+      "source": "refs/changes/34/1234/1"
+    }
+  }
+----
+
+As response a link:#change-info[ChangeInfo] entity with current revision is
+returned that describes the resulting change.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc",
+    "project": "test",
+    "branch": "master",
+    "hashtags": [],
+    "change_id": "Ic5466d107c5294414710935a8ef3b0180fb848dc",
+    "subject": "Merge dev_branch into master",
+    "status": "NEW",
+    "created": "2016-09-23 18:08:53.238000000",
+    "updated": "2016-09-23 18:09:25.934000000",
+    "submit_type": "MERGE_IF_NECESSARY",
+    "mergeable": true,
+    "insertions": 5,
+    "deletions": 0,
+    "_number": 72,
+    "owner": {
+      "_account_id": 1000000
+    },
+    "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822"
+  }
+----
+
+[[set-message]]
+=== Set Commit Message
+--
+'PUT /changes/link:#change-id[\{change-id\}]/message'
+--
+
+Creates a new patch set with a new commit message.
+
+The new commit message must be provided in the request body inside a
+link:#commit-message-input[CommitMessageInput] entity and contain the change ID footer if
+link:project-configuration.html#require-change-id[Require Change-Id] was specified.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/message HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "New Commit message \n\nChange-Id: I10394472cbd17dd12454f229e4f6de00b143a444\n"
+  }
+----
+
+.Notifications
+
+An email will be sent using the "newpatchset" template.
+
+[options="header",cols="1,1"]
+|=============================
+|WIP State       |Default
+|Ready for review|owner, reviewers, CCs, stars, NEW_PATCHSETS watchers
+|Work in progress|owner
+|=============================
+
 [[get-topic]]
 === Get Topic
 --
@@ -1038,6 +1077,36 @@
 
 If the change had no assignee the response is "`204 No Content`".
 
+[[get-pure-revert]]
+=== Get Pure Revert
+--
+'GET /changes/link:#change-id[\{change-id\}]/pure_revert'
+--
+
+Check if the given change is a pure revert of the change it references in `revertOf`.
+Optionally, the query parameter `o` can be passed in to specify a commit (SHA1 in
+40 digit hex representation) to check against. It takes precedence over `revertOf`.
+If the change has no reference in `revertOf`, the parameter is mandatory.
+
+As response a link:#pure-revert-info[PureRevertInfo] entity is returned.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/pure_revert?o=247bccf56ae47634650bcc08b8aa784c3580ccas HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "is_pure_revert" : false
+  }
+----
+
 [[abandon-change]]
 === Abandon Change
 --
@@ -1096,6 +1165,19 @@
   change is merged
 ----
 
+.Notifications
+
+An email will be sent using the "abandon" template. The notify handling is ALL.
+Notifications are suppressed on WIP changes that have never started review.
+
+[options="header",cols="1,2"]
+|=============================
+|WIP State       |notify=ALL
+|Ready for review|owner, reviewers, CCs, stars, ABANDONED_CHANGES watchers
+|Work in progress|not sent
+|Reviewable WIP  |owner, reviewers, CCs, stars, ABANDONED_CHANGES watchers
+|=============================
+
 [[restore-change]]
 === Restore Change
 --
@@ -1304,6 +1386,9 @@
   }
 ----
 
+Note that this endpoint will not update the change's parents, which is
+different from the link:#cherry-pick[cherry-pick] endpoint.
+
 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.
@@ -1753,25 +1838,6 @@
 includes changes the caller cannot read.
 
 
-[[publish-draft-change]]
-=== Publish Draft Change
---
-'POST /changes/link:#change-id[\{change-id\}]/publish'
---
-
-Publishes a draft change.
-
-.Request
-----
-  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/publish HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-----
-
-.Response
-----
-  HTTP/1.1 204 No Content
-----
-
 [[delete-change]]
 === Delete Change
 --
@@ -1784,10 +1850,6 @@
 the link:access-control.html#category_delete_own_changes[Delete Own Changes] permission,
 otherwise only by administrators.
 
-Draft changes can only be deleted by their owner or other users who have the
-permissions to view and delete drafts. If the draft workflow is disabled, only
-administrators with those permissions may delete draft changes.
-
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940 HTTP/1.0
@@ -2112,6 +2174,201 @@
   }
 ----
 
+[[set-work-in-pogress]]
+=== Set Work-In-Progress
+--
+'POST /changes/link:#change-id[\{change-id\}]/wip'
+--
+
+Marks the change as not ready for review yet. Changes may only be marked not
+ready by the owner, project owners or site administrators.
+
+The request body does not need to include a
+link:#work-in-progress-input[WorkInProgressInput] entity if no review comment
+is added. Actions that create a new patch set in a WIP change default to
+notifying *OWNER* instead of *ALL*.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/wip HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "Refactoring needs to be done before we can proceed here."
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
+[[set-ready-for-review]]
+=== Set Ready-For-Review
+--
+'POST /changes/link:#change-id[\{change-id\}]/ready'
+--
+
+Marks the change as ready for review (set WIP property to false). Changes may
+only be marked ready by the owner, project owners or site administrators.
+
+Activates notifications of reviewer. The request body does not need
+to include a link:#work-in-progress-input[WorkInProgressInput] entity
+if no review comment is added.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ready HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "message": "Refactoring is done."
+  }
+
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
+[[mark-private]]
+=== Mark Private
+--
+'POST /changes/link:#change-id[\{change-id\}]/private'
+--
+
+Marks the change to be private. Changes may only be marked private by the
+owner or site administrators.
+
+A message can be specified in the request body inside a
+link:#private-input[PrivateInput] entity.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "After this security fix has been released we can make it public now."
+  }
+----
+
+.Response
+----
+  HTTP/1.1 201 Created
+----
+
+If the change was already private the response is "`200 OK`".
+
+[[unmark-private]]
+=== Unmark Private
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/private'
+--
+
+Marks the change to be non-private. Note users can only unmark own private
+changes.
+
+A message can be specified in the request body inside a
+link:#private-input[PrivateInput] entity.
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "This is a security fix that must not be public."
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+If the change was already not private, the response is "`409 Conflict`".
+
+Please note that some proxies prohibit request bodies for DELETE
+requests. In this case, if you want to set a message options, use a
+POST request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private.delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "This is a security fix that must not be public."
+  }
+----
+
+[[ignore]]
+=== Ignore
+--
+'PUT /changes/link:#change-id[\{change-id\}]/ignore'
+--
+
+Marks a change as ignored. The change will not be shown in the incoming
+reviews dashboard, and email notifications will be suppressed. Ignoring
+a change does not cause the change's "updated" timestamp to be modified,
+and the owner is not notified.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ignore HTTP/1.0
+----
+
+[[unignore]]
+=== Unignore
+--
+'PUT /changes/link:#change-id[\{change-id\}]/unignore'
+--
+
+Un-marks a change as ignored.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unignore HTTP/1.0
+----
+
+[[mark-as-reviewed]]
+=== Mark as Reviewed
+--
+'PUT /changes/link:#change-id[\{change-id\}]/reviewed'
+--
+
+Marks a change as reviewed.
+
+This allows users to "de-highlight" changes in their dashboard until a new
+patch set is uploaded.
+
+This differs from the link:#ignore[ignore] endpoint, which will mute
+emails and hide the change from dashboard completely until it is
+link:#unignore[unignored] again.
+
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewed HTTP/1.0
+----
+
+[[mark-as-unreviewed]]
+=== Mark as Unreviewed
+--
+'PUT /changes/link:#change-id[\{change-id\}]/unreviewed'
+--
+
+Marks a change as unreviewed.
+
+This allows users to "highlight" changes in their dashboard
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unreviewed HTTP/1.0
+----
+
 [[get-hashtags]]
 === Get Hashtags
 --
@@ -2185,6 +2442,7 @@
   ]
 ----
 
+
 [[edit-endpoints]]
 == Change Edit Endpoints
 
@@ -2239,7 +2497,9 @@
        "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"
+    "base_patch_set_number":1,
+    "base_revision":"c35558e0925e6985c91f3a16921537d5e572b7a3",
+    "ref":"refs/users/01/1000001/edit-76482/1"
   }
 ----
 
@@ -2554,6 +2814,7 @@
   HTTP/1.1 204 No Content
 ----
 
+
 [[reviewer-endpoints]]
 == Reviewer Endpoints
 
@@ -2610,6 +2871,8 @@
 Suggest the reviewers for a given query `q` and result limit `n`. If result
 limit is not passed, then the default 10 is used.
 
+Groups can be excluded from the results by specifying 'e=f'.
+
 As result a list of link:#suggested-reviewer-info[SuggestedReviewerInfo] entries is returned.
 
 .Request
@@ -2709,16 +2972,16 @@
 
   )]}'
   {
+    "input": "john.doe@example.com",
     "reviewers": [
       {
-        "input": "john.doe@example.com",
+        "_account_id": 1000096,
+        "name": "John Doe",
+        "email": "john.doe@example.com"
         "approvals": {
           "Verified": " 0",
           "Code-Review": " 0"
         },
-        "_account_id": 1000096,
-        "name": "John Doe",
-        "email": "john.doe@example.com"
       }
     ]
   }
@@ -2769,6 +3032,52 @@
   }
 ----
 
+If link:config-project-config.html#reviewer.enableByEmail[reviewer.enableByEmail] is set
+for the project, reviewers and CCs are not required to have a Gerrit account. If you POST
+an email address of a reviewer or CC then, they will be added to the change even if they
+don't have a Gerrit account.
+
+If this option is disabled, the request would fail with `400 Bad Request` if the email
+address can't be resolved to an active Gerrit account.
+
+Note that the name is optional so both "un.registered@reviewer.com" and
+"John Doe <un.registered@reviewer.com>" are valid inputs.
+
+Reviewers without Gerrit accounts can only be added on changes visible to anonymous users.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reviewer": "John Doe <un.registered@reviewer.com>"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "input": "John Doe <un.registered@reviewer.com>"
+  }
+----
+
+.Notifications
+
+An email will be sent using the "newchange" template.
+
+[options="header",cols="1,1,1"]
+|=============================
+|WIP State       |Default|notify=ALL
+|Ready for review|owner, reviewers, CCs|owner, reviewers, CCs
+|Work in progress|not sent|owner, reviewers, CCs
+|=============================
+
 [[delete-reviewer]]
 === Delete Reviewer
 --
@@ -2806,6 +3115,19 @@
   HTTP/1.1 204 No Content
 ----
 
+.Notifications
+
+An email will be sent using the "deleteReviewer" template. If deleting the
+reviewer resulted in one or more approvals being removed, then the deleted
+reviewer will also receive a notification (unless notify=NONE).
+
+[options="header",cols="1,5"]
+|=============================
+|WIP State       |Default Recipients
+|Ready for review|notify=ALL: deleted reviewer (if voted), owner, reviewers, CCs, stars, ALL_COMMENTS watchers
+|Work in progress|notify=NONE: deleted reviewer (if voted)
+|=============================
+
 [[list-votes]]
 === List Votes
 --
@@ -2874,6 +3196,7 @@
   HTTP/1.1 204 No Content
 ----
 
+
 [[revision-endpoints]]
 == Revision Endpoints
 
@@ -3257,6 +3580,7 @@
   {
     "changes": [
       {
+        "project": "gerrit",
         "change_id": "Ic62ae3103fca2214904dbf2faf4c861b5f0ae9b5",
         "commit": {
           "commit": "78847477532e386f5a2185a4e8c90b2509e354e3",
@@ -3279,6 +3603,7 @@
         "status": "NEW"
       },
       {
+        "project": "gerrit",
         "change_id": "I5e4fc08ce34d33c090c9e0bf320de1b17309f774",
         "commit": {
           "commit": "b1cb4caa6be46d12b94c25aa68aebabcbb3f53fe",
@@ -3311,11 +3636,17 @@
 'POST /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/review'
 --
 
-Sets a review on a revision.
+Sets a review on a revision, optionally also publishing draft comments, setting
+labels, adding reviewers or CCs, and modifying the work in progress property.
 
 The review must be provided in the request body as a
 link:#review-input[ReviewInput] entity.
 
+A review cannot be set on a change edit. Trying to post a review for a
+change edit fails with `409 Conflict`.
+
+Here is an example of using this method to set labels:
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/review HTTP/1.0
@@ -3351,8 +3682,9 @@
   }
 ----
 
-As response a link:#review-info[ReviewInfo] entity is returned that
-describes the applied labels.
+As response a link:#review-result[ReviewResult] entity is returned that
+describes the applied labels and any added reviewers (e.g. yourself,
+if you set a label but weren't previously a reviewer on this CL).
 
 .Response
 ----
@@ -3368,11 +3700,8 @@
   }
 ----
 
-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.
+It is also possible to add one or more reviewers or CCs
+to a change simultaneously with a review:
 
 .Request
 ----
@@ -3380,16 +3709,17 @@
   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
-    },
+    "message": "I don't have context here. Jane and maybe John and the project leads should take a look.",
     "reviewers": [
       {
         "reviewer": "jane.roe@example.com"
       },
       {
-        "reviewer": "john.doe@example.com"
+        "reviewer": "john.doe@example.com",
+        "state": "CC"
+      }
+      {
+        "reviewer": "MyProjectVerifiers",
       }
     ]
   }
@@ -3397,8 +3727,8 @@
 
 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].
+adding each reviewer will be returned in a map of inputs to
+link:#add-reviewer-result[AddReviewerResult]s.
 
 .Response
 ----
@@ -3408,36 +3738,66 @@
 
   )]}'
   {
-    "labels": {
-      "Code-Review": 1
-    },
-    "reviewers": [
-      {
+    "reviewers": {
+      "jane.roe@example.com": {
         "input": "jane.roe@example.com",
-        "approvals": {
-          "Verified": " 0",
-          "Code-Review": " 0"
-        },
-        "_account_id": 1000097,
-        "name": "Jane Roe",
-        "email": "jane.roe@example.com"
+        "reviewers": [
+          {
+            "_account_id": 1000097,
+            "name": "Jane Roe",
+            "email": "jane.roe@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          },
+        ]
       },
-      {
+      "john.doe@example.com": {
         "input": "john.doe@example.com",
-        "approvals": {
-          "Verified": " 0",
-          "Code-Review": " 0"
-        },
-        "_account_id": 1000096,
-        "name": "John Doe",
-        "email": "john.doe@example.com"
+        "ccs": [
+          {
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          }
+        ]
+      },
+      "MyProjectVerifiers": {
+        "input": "MyProjectVerifiers",
+        "reviewers": [
+          {
+            "_account_id": 1000098,
+            "name": "Alice Ansel",
+            "email": "alice.ansel@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          },
+          {
+            "_account_id": 1000099,
+            "name": "Bob Bollard",
+            "email": "bob.bollard@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          },
+        ]
       }
-    ]
+    }
   }
 ----
 
 If there are any errors returned for reviewers, the entire review request will
-be rejected with `400 Bad Request`.
+be rejected with `400 Bad Request`. None of the entries will have the
+`reviewers` or `ccs` field set, and those which specifically failed will have
+the `errors` field set containing details of why they failed.
 
 .Error Response
 ----
@@ -3448,6 +3808,13 @@
   )]}'
   {
     "reviewers": {
+      "jane.roe@example.com": {
+        "input": "jane.roe@example.com",
+        "error": "Account of jane.roe@example.com is inactive."
+      },
+      "john.doe@example.com": {
+        "input": "john.doe@example.com"
+      },
       "MyProjectVerifiers": {
         "input": "MyProjectVerifiers",
         "error": "The group My Group has 15 members. Do you want to add them all as reviewers?",
@@ -3457,6 +3824,44 @@
   }
 ----
 
+[[set-review-notifications]]
+.Notifications
+
+An email will be sent using the "comment" template.
+
+If the top-level notify property is null or not set, then notification behavior
+depends on whether the change is WIP, whether it has started review, and whether
+the tag property is null.
+
+NOTE: If adding reviewers, the notify property of each ReviewerInput is *ignored*.
+Use the notify property of the top-level link:#review-input[ReviewInput] instead.
+
+For the purposes of this table, *everyone* means *owner, reviewers, CCs, stars, and ALL_COMMENTS
+watchers*.
+
+[options="header",cols="2,1,1,2,2"]
+|=============================
+|WIP State       |Review Started|Tag Given|Default |notify=ALL
+|Ready for review|N/A           |N/A      |everyone|everyone
+|Work in progress|no            |no       |not sent|everyone
+|Work in progress|no            |yes      |owner   |everyone
+|Work in progress|yes           |no       |everyone|everyone
+|Work in progress|yes           |yes      |owner   |everyone
+
+|=============================
+
+If reviewers are added, then a second email will be sent using the "newchange"
+template. The notification logic for this email is the same as for
+link:#add-reviewer[Add Reviewer].
+
+[options="header",cols="1,1,1"]
+|=============================
+|WIP State       |Default              |notify=ALL
+|Ready for review|owner, reviewers, CCs|owner, reviewers, CCs
+|Work in progress|not sent             |owner, reviewers, CCs
+|=============================
+
+
 [[rebase-revision]]
 === Rebase Revision
 --
@@ -3598,44 +4003,6 @@
   "revision 674ac754f91e64a0efb8087e59a176484bd534d1 is not current revision"
 ----
 
-[[publish-draft-revision]]
-=== Publish Draft Revision
---
-'POST /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/publish'
---
-
-Publishes a draft revision.
-
-.Request
-----
-  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/publish HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-----
-
-.Response
-----
-  HTTP/1.1 204 No Content
-----
-
-[[delete-draft-revision]]
-=== Delete Draft Revision
---
-'DELETE /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]'
---
-
-Deletes a draft revision.
-
-.Request
-----
-  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1 HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-----
-
-.Response
-----
-  HTTP/1.1 204 No Content
-----
-
 [[get-patch]]
 === Get Patch
 --
@@ -4165,6 +4532,62 @@
   }
 ----
 
+[[delete-comment]]
+=== Delete Comment
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/comments/link:#comment-id[\{comment-id\}]' +
+'POST /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/comments/link:#comment-id[\{comment-id\}]/delete'
+--
+
+Deletes a published comment of a revision. Instead of deleting the
+whole comment, this endpoint just replaces the comment's message
+with a new message, which contains the name of the user who deletes
+the comment and the reason why it's deleted. The reason can be
+provided in the request body as a
+link:#delete-comment-input[DeleteCommentInput] entity.
+
+Note that only users with the
+link:access-control.html#capability_administrateServer[Administrate Server]
+global capability are permitted to delete a comment.
+
+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/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/comments/TvcXrmjM/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reason": "contains confidential information"
+  }
+----
+
+As response a link:#comment-info[CommentInfo] entity is returned that
+describes the updated comment.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "TvcXrmjM",
+    "path": "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java",
+    "line": 23,
+    "message": "Comment removed by: Administrator; Reason: contains confidential information",
+    "updated": "2013-02-26 15:40:43.986000000",
+    "author": {
+      "_account_id": 1000096,
+      "name": "John Doe",
+      "email": "john.doe@example.com"
+    }
+  }
+----
+
 [[list-robot-comments]]
 === List Robot Comments
 --
@@ -4261,6 +4684,74 @@
   }
 ----
 
+[[apply-fix]]
+=== Apply Fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/apply'
+--
+
+Applies a suggested fix by creating a change edit which includes the
+modifications indicated by the fix suggestion. If a change edit already exists,
+it will be updated accordingly. A fix can only be applied if no change edit
+exists and the fix refers to the current patch set, or the fix refers to the
+patch set on which the change edit is based.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fixes/8f605a55_f6aa4ecc/apply HTTP/1.0
+----
+
+If the fix was successfully applied, an <<edit-info,EditInfo>> describing the
+resulting change edit is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+    )]}'
+    {
+      "commit":{
+        "parents":[
+          {
+            "commit":"1eee2c9d8f352483781e772f35dc586a69ff5646",
+          }
+        ],
+        "author":{
+          "name":"John Doe",
+          "email":"john.doe@example.com",
+          "date":"2013-05-07 15:21:27.000000000",
+          "tz":120
+         },
+         "committer":{
+           "name":"Jane Doe",
+           "email":"jane.doe@example.com",
+           "date":"2013-05-07 15:35:43.000000000",
+           "tz":120
+         },
+         "subject":"Implement feature X",
+         "message":"Implement feature X\n\nWith this feature ..."
+      },
+      "base_patch_set_number":1,
+      "base_revision":"674ac754f91e64a0efb8087e59a176484bd534d1"
+      "ref":"refs/users/01/1000001/edit-42622/1"
+    }
+----
+
+If the application failed e.g. due to conflicts with an existing change edit,
+the response "`409 Conflict`" including an error message in the response body
+is returned.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  The existing change edit could not be merged with another tree.
+----
+
 [[list-files]]
 === List Files
 --
@@ -4308,12 +4799,22 @@
 The request parameter `q` changes the response to return a list
 of all files (modified or unmodified) that contain that substring
 in the path name. This is useful to implement suggestion services
-finding a file by partial name.
+finding a file by partial name.  Clients that also need the FileInfo
+should make two requests.
 
-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.
+For merge commits only, the integer-valued request parameter `parent`
+changes the response to return a map of the files which are different
+in this commit compared to the given parent commit. The value is the
+1-based index of the parent's position in the commit object. If not
+specified, the response contains a map of the files different in the
+auto merge result.
+
+The request parameter `base` changes the response to return a map of the
+files which are different in this commit compared to the given revision. The
+revision must correspond to a patch set in the change.
+
+The `reviewed`, `q`, `parent`, and `base` options are mutually exclusive.
+That is, only one of them may be used at a time.
 
 .Request
 ----
@@ -4341,6 +4842,11 @@
 
 Gets the content of a file from a certain revision.
 
+The optional, integer-valued `parent` parameter can be specified to request
+the named file from a parent commit of the specified revision. The value is
+the 1-based index of the parent's position in the commit object. If the
+parameter is omitted or the value is non-positive, the patch set is referenced.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/gerrit-server%2Fsrc%2Fmain%2Fjava%2Fcom%2Fgoogle%2Fgerrit%2Fserver%2Fproject%2FRefControl.java/content HTTP/1.0
@@ -4715,7 +5221,8 @@
 Cherry picks a revision to a destination branch.
 
 The commit message and destination branch must be provided in the request body inside a
-link:#cherrypick-input[CherryPickInput] entity.
+link:#cherrypick-input[CherryPickInput] entity.  If the commit message
+does not specify a Change-Id, a new one is picked for the destination change.
 
 .Request
 ----
@@ -4892,16 +5399,18 @@
 
 [[change-id]]
 === \{change-id\}
-Identifier that uniquely identifies one change.
+Identifier that uniquely identifies one change. It contains the URL-encoded
+project name as well as the change number: "'$$<project>~<numericId>$$'"
 
-This can be:
+Gerrit still supports the following deprecated identifiers. These will be
+removed in a future release:
 
 * an ID of the change in the format "'$$<project>~<branch>~<Change-Id>$$'",
   where for the branch the `refs/heads/` prefix can be omitted
   ("$$myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940$$")
 * a Change-Id if it uniquely identifies one change
   ("I8473b95934b5732ac55d26311a706c9c2bde9940")
-* a legacy numeric change ID ("4247")
+* a numeric change ID ("4247")
 
 [[comment-id]]
 === \{comment-id\}
@@ -5049,8 +5558,9 @@
 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
+while posting the review. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
+NOTE: To apply different tags on different votes/comments multiple
 invocations of the REST call are required.
 |`post_submit` |not set if `false`|
 If true, this vote was made after the change was submitted.
@@ -5124,11 +5634,17 @@
 The name of the target branch. +
 The `refs/heads/` prefix is omitted.
 |`topic`              |optional|The topic to which this change belongs.
+|`assignee`           |optional|
+The assignee of the change as an link:rest-api-accounts.html#account-info[
+AccountInfo] entity.
+|`hashtags`           |optional|
+List of hashtags that are set on the change (only populated when NoteDb
+is enabled).
 |`change_id`          ||The Change-Id of the change.
 |`subject`            ||
 The subject of the change (header line of the commit message).
 |`status`             ||
-The status of the change (`NEW`, `MERGED`, `ABANDONED`, `DRAFT`).
+The status of the change (`NEW`, `MERGED`, `ABANDONED`).
 |`created`            ||
 The link:rest-api.html#timestamp[timestamp] of when the change was
 created.
@@ -5138,6 +5654,9 @@
 |`submitted`          |only set for merged changes|
 The link:rest-api.html#timestamp[timestamp] of when the change was
 submitted.
+|`submitter`          |only set for merged changes|
+The user who submitted the change, as an
+link:rest-api-accounts.html#account-info[ AccountInfo] entity.
 |`starred`            |not set if `false`|
 Whether the calling user has starred this change with the default label.
 |`stars`              |optional|
@@ -5182,7 +5701,7 @@
 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`          ||
+|`reviewers`          |optional|
 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`. +
@@ -5191,6 +5710,12 @@
 `REMOVED`: Users that were previously reviewers on the change, but have
 been removed. +
 Only set if link:#detailed-labels[detailed labels] are requested.
+|`pending_reviewers`  |optional|
+Updates to `reviewers` that have been made while the change was in the
+WIP state. Only present on WIP changes and only if there are pending
+reviewer updates to report. These are reviewers who have not yet been
+notified about being added to or removed from the change. +
+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.
@@ -5210,12 +5735,24 @@
 Only set if link:#current-revision[the current revision] is requested
 (in which case it will only contain a key for the current revision) or
 if link:#all-revisions[all revisions] are requested.
+|`tracking_ids`       |optional|
+A list of link:#tracking-id-info[TrackingIdInfo] entities describing
+references to external tracking systems. Only set if
+link:#tracking-ids[tracking ids] are requested.
 |`_more_changes`      |optional, not set if `false`|
 Whether the query would deliver more results if not limited. +
 Only set on the last change that is returned.
 |`problems`           |optional|
 A list of link:#problem-info[ProblemInfo] entities describing potential
 problems with this change. Only set if link:#check[CHECK] is set.
+|`is_private`         |optional, not set if `false`|
+When present, change is marked as private.
+|`work_in_progress`   |optional, not set if `false`|
+When present, change is marked as Work In Progress.
+|`has_review_started` |optional, not set if `false`|
+When present, change has been marked Ready at some point in time.
+|`revert_of`          |optional|
+The numeric Change-Id of the change that this change reverts.
 |==================================
 
 [[change-input]]
@@ -5234,7 +5771,11 @@
 be removed.
 |`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).
+The status of the change (only `NEW` accepted here).
+|`is_private`         |optional, default to `false`|
+Whether the new change should be marked as private.
+|`work_in_progress`   |optional, default to `false`|
+Whether the new change should be set to work in progress.
 |`base_change`        |optional|
 A link:#change-id[\{change-id\}] that identifies the base change for a create
 change operation.
@@ -5265,13 +5806,18 @@
 Author of the message as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity. +
 Unset if written by the Gerrit system.
+|`real_author`         |optional|
+Real author of the message as an
+link:rest-api-accounts.html#account-info[AccountInfo] entity. +
+Set if the message was posted on behalf of another user.
 |`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
+while posting the review. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
+NOTE: To apply different tags on different votes/comments multiple
 invocations of the REST call are required.
 |`_revision_number`    |optional|
 Which patchset (if any) generated this message.
@@ -5286,8 +5832,21 @@
 |Field Name         ||Description
 |`message`          ||Commit message for the cherry-picked change
 |`destination`      ||Destination branch
+|`base`             |optional|
+40-hex digit SHA-1 of the commit which will be the parent commit of the newly created change.
+If set, it must be a merged commit or a change revision on the destination branch.
 |`parent`           |optional, defaults to 1|
 Number of the parent relative to which the cherry-pick should be considered.
+|`notify`           |optional|
+Notify handling that defines to whom email notifications should be sent
+after the cherry-pick. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `NONE`.
+|`notify_details`   |optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
+|`keep_reviewers`   |optional, defaults to false|
+If true, carries reviewers and ccs over from original change to newly created one.
 |===========================
 
 [[comment-info]]
@@ -5331,7 +5890,7 @@
 |`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
+NOTE: To apply different tags on different votes/comments multiple
 invocations of the REST call are required.
 |`unresolved`        |optional|
 Whether or not the comment must be addressed by the user. The state of
@@ -5378,7 +5937,8 @@
 |`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]
+link#review-input[ReviewInput]. Votes/comments that contain `tag` with
+'autogenerated:' prefix can be filtered out in the web UI.
 |`unresolved`        |optional|
 Whether or not the comment must be addressed by the user. This value will
 default to false if the comment is an orphan, or the value of the `in_reply_to`
@@ -5425,6 +5985,39 @@
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
+[[commit-message-input]]
+=== CommitMessageInput
+The `CommitMessageInput` entity contains information for changing
+the commit message of a change.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name      ||Description
+|`message`       ||New commit message.
+|`notify`        |optional|
+Notify handling that defines to whom email notifications should be sent
+after the commit message was updated. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `OWNER` for WIP changes and `ALL` otherwise.
+|`notify_details`|optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
+|=============================
+
+[[delete-comment-input]]
+=== DeleteCommentInput
+The `DeleteCommentInput` entity contains the option for deleting a comment.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name               ||Description
+|`reason`                 |optional|
+The reason why the comment should be deleted. +
+If set, the comment's message will be replaced with
+"Comment removed by: `name`; Reason: `reason`",
+or just "Comment removed by: `name`." if not set.
+|=============================
+
 [[delete-reviewer-input]]
 === DeleteReviewerInput
 The `DeleteReviewerInput` entity contains options for the deletion of a
@@ -5481,19 +6074,21 @@
 
 [options="header",cols="1,^1,5"]
 |==========================
-|Field Name ||Description
-|`a`        |optional|Content only in the file on side A (deleted in B).
-|`b`        |optional|Content only in the file on side B (added in B).
-|`ab`       |optional|Content in the file on both sides (unchanged).
-|`edit_a`   |only present during a replace, i.e. both `a` and `b` are present|
+|Field Name     ||Description
+|`a`            |optional|Content only in the file on side A (deleted in B).
+|`b`            |optional|Content only in the file on side B (added in B).
+|`ab`           |optional|Content in the file on both sides (unchanged).
+|`edit_a`       |only present during a replace, i.e. both `a` and `b` are present|
 Text sections deleted from side A as a
 link:#diff-intraline-info[DiffIntralineInfo] entity.
-|`edit_b`   |only present during a replace, i.e. both `a` and `b` are present|
+|`edit_b`       |only present during a replace, i.e. both `a` and `b` are present|
 Text sections inserted in side B as a
 link:#diff-intraline-info[DiffIntralineInfo] entity.
-|`skip`     |optional|count of lines skipped on both sides when the file is
+|`due_to_rebase`|not set if `false`|Indicates whether this entry was introduced by a
+rebase.
+|`skip`         |optional|count of lines skipped on both sides when the file is
 too large to include all common lines.
-|`common`   |optional|Set to `true` if the region is common according
+|`common`       |optional|Set to `true` if the region is common according
 to the requested ignore-whitespace parameter, but a and b contain
 differing amounts of whitespace. When present and true a and b are
 used instead of ab.
@@ -5594,15 +6189,17 @@
 
 [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.
-|`base_revision`||The revision of the patch set the change edit is based on.
-|`fetch`        ||
+|`base_patch_set_number`||The patch set number of the patch set the change edit is based on.
+|`base_revision`        ||The revision of the patch set the change edit is based on.
+|`ref`                  ||The ref of the change edit.
+|`fetch`                |optional|
 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.
 |===========================
@@ -5677,8 +6274,8 @@
 for input objects.
 |`description`      ||A description of the suggested fix.
 |`replacements`     ||A list of <<fix-replacement-info,FixReplacementInfo>>
-entities indicating how the content of the file on which the comment was placed
-should be modified. They should refer to non-overlapping regions.
+entities indicating how the content of one or several files should be modified.
+Within a file, they should refer to non-overlapping regions.
 |==========================
 
 [[fix-replacement-info]]
@@ -5689,10 +6286,13 @@
 [options="header",cols="1,6"]
 |==========================
 |Field Name      |Description
-|`path`          |The path of the file which should be modified. Modifications
-are only allowed for the file on which the corresponding comment was placed.
+|`path`          |The path of the file which should be modified. Any file in
+the repository may be modified.
 |`range`         |A <<comment-range,CommentRange>> indicating which content
-of the file should be replaced.
+of the file should be replaced. Lines in the file are assumed to be separated
+by the line feed character, the carriage return character, the carriage return
+followed by the line feed character, or one of the other Unicode linebreak
+sequences supported by Java.
 |`replacement`   |The content which should be used instead of the current one.
 |==========================
 
@@ -5805,7 +6405,9 @@
 |===========================
 |Field Name    ||Description
 |`all`         |optional|List of all approvals for this label as a list
-of link:#approval-info[ApprovalInfo] entities.
+of link:#approval-info[ApprovalInfo] entities. Items in this list may
+not represent actual votes cast by users; if a user votes on any label,
+a corresponding ApprovalInfo will appear in this list for all labels.
 |`values`      |optional|A map of all values that are allowed for this
 label. The map maps the values ("`-2`", "`-1`", " `0`", "`+1`", "`+2`")
 to the value descriptions.
@@ -5902,6 +6504,17 @@
 identify the accounts that should be should be notified.
 |=======================
 
+[[private-input]]
+=== PrivateInput
+The `PrivateInput` entity contains information for changing the private
+flag on a change.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`message` |optional|Message describing why the private flag was changed.
+|=======================
+
 [[problem-info]]
 === ProblemInfo
 The `ProblemInfo` entity contains a description of a potential consistency problem
@@ -5939,6 +6552,16 @@
 of recipient type to link:#notify-info[NotifyInfo] entity.
 |=============================
 
+[[pure-revert-info]]
+=== PureRevertInfo
+The `PureRevertInfo` entity describes the result of a pure revert check.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name      |Description
+|`is_pure_revert`  |Outcome of the check as boolean.
+|======================
+
 [[push-certificate-info]]
 === PushCertificateInfo
 The `PushCertificateInfo` entity contains information about a push
@@ -5991,6 +6614,7 @@
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name                ||Description
+|`project`                 ||The project of the change or commit.
 |`change_id`               |optional|The Change-Id of the change.
 |`commit`                  ||The commit as a
 link:#commit-info[CommitInfo] entity.
@@ -5998,7 +6622,7 @@
 |`_revision_number`        |optional|The revision number.
 |`_current_revision_number`|optional|The current revision number.
 |`status`                  |optional|The status of the change. The status of
-the change is one of (`NEW`, `MERGED`, `ABANDONED`, `DRAFT`).
+the change is one of (`NEW`, `MERGED`, `ABANDONED`).
 |===========================
 
 [[related-changes-info]]
@@ -6084,8 +6708,8 @@
 |`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.
+distinguish them from human reviews. Votes/comments that contain `tag` with
+'autogenerated:' prefix 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.
@@ -6095,13 +6719,6 @@
 |`robot_comments`         |optional|
 The robot comments that should be added as a map that maps a file path
 to a list of link:#robot-comment-input[RobotCommentInput] entities.
-|`strict_labels`          |`true` if not set|
-Whether all labels are required to be within the user's permitted ranges
-based on access controls. +
-If `true`, attempting to use a label not granted to the user will fail
-the entire modify operation early. +
-If `false`, the operation will execute anyway, but the proposed labels
-will be modified to be the "best" value allowed by the access controls.
 |`drafts`                 |optional|
 Draft handling that defines how draft comments are handled that are
 already in the database but that were not also described in this
@@ -6126,6 +6743,36 @@
 link:rest-api-accounts.html#account-id[\{account-id\}] the review
 should be posted on behalf of. To use this option the caller must
 have been granted `labelAs-NAME` permission for all keys of labels.
+|`reviewers`              |optional|
+A list of link:rest-api-changes.html#reviewer-input[ReviewerInput]
+representing reviewers that should be added to the change.
+|`ready`                  |optional|
+If true, and if the change is work in progress, then start review.
+It is an error for both `ready` and `work_in_progress` to be true.
+|`work_in_progress`         |optional|
+If true, mark the change as work in progress. It is an error for both
+`ready` and `work_in_progress` to be true.
+|============================
+
+[[review-result]]
+=== ReviewResult
+The `ReviewResult` entity contains information regarding the updates
+that were made to a review.
+
+[options="header",cols="1,^1,5"]
+|============================
+|Field Name               ||Description
+|`labels`                 |optional|
+Map of labels to values after the review was posted. Null if any reviewer
+additions were rejected.
+|`reviewers`              |optional|
+Map of account or group identifier to
+link:rest-api-changes.html#add-reviewer-result[AddReviewerResult]
+representing the outcome of adding as a reviewer.
+Absent if no reviewer additions were requested.
+|`ready`                  |optional|
+If true, the change was moved from WIP to ready for review as a result of this
+action. Not set if false.
 |============================
 
 [[reviewer-info]]
@@ -6144,6 +6791,10 @@
 |`approvals`   |
 The approvals of the reviewer as a map that maps the label names to the
 approval values ("`-2`", "`-1`", "`0`", "`+1`", "`+2`").
+|`_account_id`   |
+This field is inherited from `AccountInfo` but is optional here if an
+unregistered reviewer was added by email. See
+link:rest-api-changes.html#add-reviewer[add-reviewer] for details.
 |==========================
 
 [[reviewer-input]]
@@ -6188,7 +6839,6 @@
 [options="header",cols="1,^1,5"]
 |===========================
 |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.
@@ -6232,6 +6882,9 @@
 patch set as a link:#push-certificate-info[PushCertificateInfo] entity.
 This field is always set if the option is requested; if no push
 certificate was provided, it is set to an empty object.
+|`description` |optional|
+The description of this patchset, as displayed in the patchset
+selector menu. May be null if no description is set.
 |===========================
 
 [[robot-comment-info]]
@@ -6417,6 +7070,17 @@
 The topic will be deleted if not set.
 |===========================
 
+[[tracking-id-info]]
+=== TrackingIdInfo
+The `TrackingIdInfo` entity describes a reference to an external tracking system.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`system`  |The name of the external tracking system.
+|`id`      |The tracking id.
+|======================
+
 [[voting-range-info]]
 === VotingRangeInfo
 The `VotingRangeInfo` entity describes the continuous voting range from min
@@ -6441,6 +7105,18 @@
 |`image_url`|URL to the icon of the link.
 |======================
 
+[[work-in-progress-input]]
+=== WorkInProgressInput
+The `WorkInProgressInput` entity contains additional information for a change
+set to WorkInProgress/ReadyForReview.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name      ||Description
+|`message`       |optional|
+Message to be added as a review comment to the change being set WorkInProgress/ReadyForReview.
+|=============================
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index a311f0b9..148bb2d 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -52,6 +52,9 @@
 
   )]}'
   {
+    "accounts": {
+      "visibility": "ALL"
+    },
     "auth": {
       "auth_type": "LDAP",
       "use_contributor_agreements": true,
@@ -138,6 +141,59 @@
   }
 ----
 
+[[check-consistency]]
+=== Check Consistency
+--
+'POST /config/server/check.consistency'
+--
+
+Runs consistency checks and returns detected problems.
+
+Input for the consistency checks that should be run must be provided in
+the request body inside a
+link:#consistency-check-input[ConsistencyCheckInput] entity.
+
+.Request
+----
+  POST /config/server/check.consistency HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "check_accounts": {},
+    "check_account_external_ids": {}
+  }
+----
+
+As result a link:#consistency-check-info[ConsistencyCheckInfo] entity
+is returned that contains detected consistency problems.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "check_accounts_result": {
+      "problems": [
+        {
+          "status": "ERROR",
+          "message": "Account \u00271000024\u0027 has no external ID for its preferred email \u0027foo.bar@example.com\u0027"
+        }
+      ]
+    }
+    "check_account_external_ids_result": {
+      "problems": [
+        {
+          "status": "ERROR",
+          "message": "External ID \u0027uuid:ccb8d323-1361-45aa-8874-41987a660c46\u0027 belongs to account that doesn\u0027t exist: 1000012"
+        }
+      ]
+    }
+  }
+----
+
+
 [[confirm-email]]
 === Confirm Email
 --
@@ -206,25 +262,6 @@
         "mem": 94
       }
     },
-    "accounts_byemail": {
-      "type": "MEM",
-      "entries": {
-        "mem": 4
-      },
-      "average_get": "771.8us",
-      "hit_ratio": {
-        "mem": 95
-      }
-    },
-    "accounts_byname": {
-      "type": "MEM",
-      "entries": {
-        "mem": 4
-      },
-      "hit_ratio": {
-        "mem": 100
-      }
-    },
     "adv_bases": {
       "type": "MEM",
       "entries": {},
@@ -296,7 +333,7 @@
         "mem": 12
       }
     },
-    "groups_byinclude": {
+    "groups_bymember": {
       "type": "MEM",
       "entries": {},
       "hit_ratio": {}
@@ -306,6 +343,11 @@
       "entries": {},
       "hit_ratio": {}
     },
+    "groups_bysubgroup": {
+      "type": "MEM",
+      "entries": {},
+      "hit_ratio": {}
+    },
     "groups_byuuid": {
       "type": "MEM",
       "entries": {
@@ -321,7 +363,7 @@
       "entries": {},
       "hit_ratio": {}
     },
-    groups_members": {
+    groups_subgroups": {
       "type": "MEM",
       "entries": {
         "mem": 4
@@ -422,8 +464,6 @@
   )]}'
   [
     "accounts",
-    "accounts_byemail",
-    "accounts_byname",
     "adv_bases",
     "change_kind",
     "changes",
@@ -432,11 +472,12 @@
     "diff_intraline",
     "git_tags",
     "groups",
-    "groups_byinclude",
+    "groups_bymember",
     "groups_byname",
+    "groups_bysubgroup",
     "groups_byuuid",
     "groups_external",
-    "groups_members",
+    "groups_subgroups",
     "permission_sort",
     "plugin_resources",
     "project_list",
@@ -980,16 +1021,13 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "NONE",
     "mute_common_path_prefixes": true,
+    "publish_comments_on_push": true,
     "my": [
       {
         "url": "#/dashboard/self",
         "name": "Changes"
       },
       {
-        "url": "#/q/owner:self+is:draft",
-        "name": "Drafts"
-      },
-      {
         "url": "#/q/has:draft",
         "name": "Draft Comments"
       },
@@ -1062,16 +1100,13 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "NONE",
     "mute_common_path_prefixes": true,
+    "publish_comments_on_push": true,
     "my": [
       {
         "url": "#/dashboard/self",
         "name": "Changes"
       },
       {
-        "url": "#/q/owner:self+is:draft",
-        "name": "Drafts"
-      },
-      {
         "url": "#/q/has:draft",
         "name": "Draft Comments"
       },
@@ -1221,6 +1256,20 @@
 [[json-entities]]
 == JSON Entities
 
+[[accounts-config-info]]
+=== AccountsConfigInfo
+The `AccountsConfigInfo` entity contains information about Gerrit
+configuration from the link:config-gerrit.html#accounts[accounts]
+section.
+
+[options="header",cols="1,6"]
+|=============================
+|Field Name           |Description
+|`visibility`         |
+link:config-gerrit.html#accounts.visibility[Visibility setting for
+accounts].
+|=============================
+
 [[auth-info]]
 === AuthInfo
 The `AuthInfo` entity contains information about the authentication
@@ -1345,9 +1394,6 @@
 |`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].
 |`large_change`       ||
 link:config-gerrit.html#change.largeChange[Number of changed lines from
 which on a change is considered as a large change].
@@ -1363,8 +1409,76 @@
 |`submit_whole_topic` ||
 link:config-gerrit.html#change.submitWholeTopic[A configuration if
 the whole topic is submitted].
+|`disable_private_changes` |not set if `false`|
+Returns true if private changes are disabled.
 |=============================
 
+[[check-account-external-ids-input]]
+=== CheckAccountExternalIdsInput
+The `CheckAccountExternalIdsInput` entity contains input for the
+account external IDs consistency check.
+
+Currently this entity contains no fields.
+
+[[check-account-external-ids-result-info]]
+=== CheckAccountExternalIdsResultInfo
+The `CheckAccountExternalIdsResultInfo` entity contains the result of
+running the account external IDs consistency check.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`problems`|A list of link:#consistency-problem-info[
+ConsistencyProblemInfo] entities.
+|======================
+
+[[consistency-check-info]]
+=== ConsistencyCheckInfo
+The `ConsistencyCheckInfo` entity contains the results of running
+consistency checks.
+
+[options="header",cols="1,^1,5"]
+|================================================
+|Field Name                         ||Description
+|`check_accounts_result`            |optional|
+The result of running the account consistency check as a
+link:#check-accounts-result-info[CheckAccountsResultInfo] entity.
+|`check_account_external_ids_result`|optional|
+The result of running the account external ID consistency check as a
+link:#check-account-external-ids-result-info[
+CheckAccountExternalIdsResultInfo] entity.
+|================================================
+
+[[consistency-check-input]]
+=== ConsistencyCheckInput
+The `ConsistencyCheckInput` entity contains information about which
+consistency checks should be run.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`check_accounts`            |optional|
+Input for the account consistency check as
+link:#check-accounts-input[CheckAccountsInput] entity.
+|`check_account_external_ids`|optional|
+Input for the account external ID consistency check as
+link:#check-account-external-ids-input[CheckAccountExternalIdsInput]
+entity.
+|=========================================
+
+[[consistency-problem-info]]
+=== ConsistencyProblemInfo
+The `ConsistencyProblemInfo` entity contains information about a
+consistency problem.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`status`  |The status of the consistency problem. +
+Possible values are `ERROR` and `WARNING`.
+|`message` |Message describing the consistency problem.
+|======================
+
 [[download-info]]
 === DownloadInfo
 The `DownloadInfo` entity contains information about supported download
@@ -1573,6 +1687,10 @@
 [options="header",cols="1,^1,5"]
 |=======================================
 |Field Name                ||Description
+|`accounts`                ||
+Information about the configuration from the
+link:config-gerrit.html#accounts[accounts] section as
+link:#accounts-config-info[AccountsConfigInfo] entity.
 |`auth`                    ||
 Information about the authentication configuration as
 link:#auth-info[AuthInfo] entity.
@@ -1613,6 +1731,9 @@
 Information about the configuration from the
 link:config-gerrit.html#user[user] section as link:#user-config-info[
 UserConfigInfo] entity.
+|`default_theme`           |optional|
+URL to a default PolyGerrit UI theme plugin, if available.
+Located in `/static/gerrit-theme.html` by default.
 |=======================================
 
 [[sshd-info]]
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index 61b746d..d5d7256 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -42,7 +42,8 @@
       "description": "Gerrit Site Administrators",
       "group_id": 1,
       "owner": "Administrators",
-      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+      "created_on": "2013-02-01 09:59:32.126000000"
     },
     "Anonymous Users": {
       "id": "global%3AAnonymous-Users",
@@ -52,7 +53,8 @@
       "description": "Any user, signed-in or not",
       "group_id": 2,
       "owner": "Administrators",
-      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+      "created_on": "2013-02-01 09:59:32.126000000"
     },
     "MyProject_Committers": {
       "id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7",
@@ -62,7 +64,8 @@
       },
       "group_id": 6,
       "owner": "MyProject_Committers",
-      "owner_id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7"
+      "owner_id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7",
+      "created_on": "2013-02-01 09:59:32.126000000"
     },
     "Non-Interactive Users": {
       "id": "5057f3cbd3519d6ab69364429a89ffdffba50f73",
@@ -72,7 +75,8 @@
       "description": "Users who perform batch actions on Gerrit",
       "group_id": 4,
       "owner": "Administrators",
-      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+      "created_on": "2013-02-01 09:59:32.126000000"
     },
     "Project Owners": {
       "id": "global%3AProject-Owners",
@@ -82,7 +86,8 @@
       "description": "Any owner of the project",
       "group_id": 5,
       "owner": "Administrators",
-      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+      "created_on": "2013-02-01 09:59:32.126000000"
     },
     "Registered Users": {
       "id": "global%3ARegistered-Users",
@@ -92,7 +97,8 @@
       "description": "Any signed-in user",
       "group_id": 3,
       "owner": "Administrators",
-      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+      "created_on": "2013-02-01 09:59:32.126000000"
     }
   }
 ----
@@ -110,7 +116,7 @@
 
 [[includes]]
 --
-* `INCLUDES`: include list of directly included groups.
+* `INCLUDES`: include list of direct subgroups.
 --
 
 [[members]]
@@ -154,7 +160,8 @@
       "description":"contains all committers for MyProject",
       "group_id": 551,
       "owner": "MyProject-Owners",
-      "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc"
+      "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc",
+      "created_on": "2013-02-01 09:59:32.126000000"
     }
   }
 ----
@@ -211,11 +218,60 @@
       "group_id": 1,
       "owner": "Administrators",
       "owner_id": "59b92f35489e62c80d1ab1bf0c2d17843038df8b",
+      "created_on": "2013-02-01 09:59:32.126000000",
       "id": "59b92f35489e62c80d1ab1bf0c2d17843038df8b"
     }
   }
 ----
 
+Regex(r)::
+Limit the results to those groups that match the specified regex.
++
+Boundary matchers '^' and '$' are implicit. For example: the regex 'test.*' will
+match any groups that start with 'test' and regex '.*test' will match any
+group that end with 'test'.
++
+The match is case sensitive.
++
+List all groups that match regex `test.*group`:
++
+.Request
+----
+  GET /groups/?r=test.*group HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "test/some-group": {
+      "url": "#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b",
+      "options": {},
+      "description": "Gerrit Site Administrators",
+      "group_id": 1,
+      "owner": "Administrators",
+      "owner_id": "59b92f35489e62c80d1ab1bf0c2d17843038df8b",
+      "created_on": "2013-02-01 09:59:32.126000000",
+      "id": "59b92f35489e62c80d1ab1bf0c2d17843038df8b"
+    }
+    "test/some-other-group": {
+      "url": "#/admin/groups/uuid-99b92f35489e62c80d1ab1bf0c2d17843038df8b",
+      "options": {},
+      "description": "Gerrit Site Administrators",
+      "group_id": 1,
+      "owner": "Administrators",
+      "owner_id": "99b92f35489e62c80d1ab1bf0c2d17843038df8b",
+      "created_on": "2014-02-01 09:59:32.126000000",
+      "id": "99b92f35489e62c80d1ab1bf0c2d17843038df8b"
+    }
+  }
+
+----
+
 Substring(m)::
 Limit the results to those groups that match the specified substring.
 +
@@ -286,6 +342,7 @@
       "group_id": 20,
       "owner": "MyProject-Test-Group",
       "owner_id": "59b92f35489e62c80d1ab1bf0c2d17843038df8b",
+      "created_on": "2013-02-01 09:59:32.126000000",
       "id": "68236a40ca78de8be630312d8ba50250bc5638ae"
     },
     {
@@ -295,6 +352,7 @@
       "group_id": 17,
       "owner": "ProjectX-Testers",
       "owner_id": "59b92f35489e62c80d1ab1bf0c2d17843038df8b",
+      "created_on": "2013-02-01 09:59:32.126000000",
       "id": "99a534526313324a2667025c3f4e089199b736aa"
     }
   ]
@@ -362,7 +420,8 @@
     "description": "Gerrit Site Administrators",
     "group_id": 1,
     "owner": "Administrators",
-    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+    "created_on": "2013-02-01 09:59:32.126000000"
   }
 ----
 
@@ -410,7 +469,8 @@
     "description":"contains all committers for MyProject",
     "group_id": 551,
     "owner": "MyProject-Owners",
-    "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc"
+    "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc",
+    "created_on": "2013-02-01 09:59:32.126000000"
   }
 ----
 
@@ -451,6 +511,7 @@
     "group_id": 1,
     "owner": "Administrators",
     "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+    "created_on": "2013-02-01 09:59:32.126000000",
     "members": [
       {
         "_account_id": 1000097,
@@ -700,7 +761,8 @@
     "description": "Gerrit Site Administrators",
     "group_id": 1,
     "owner": "Administrators",
-    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+    "created_on": "2013-02-01 09:59:32.126000000"
   }
 ----
 
@@ -746,7 +808,8 @@
     "description": "Gerrit Site Administrators",
     "group_id": 1,
     "owner": "Administrators",
-    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+    "created_on": "2013-02-01 09:59:32.126000000"
   }
 ----
 
@@ -783,6 +846,7 @@
         "group_id": 3,
         "owner": "Administrators",
         "owner_id": "e56678641565e7f59dd5c6878f5bcbc842bf150a",
+        "created_on": "2013-02-01 09:59:32.126000000",
         "id": "fdda826a0815859ab48d22a05a43472f0f55f89a",
         "name": "MyGroup"
       },
@@ -802,6 +866,7 @@
         "group_id": 3,
         "owner": "Administrators",
         "owner_id": "e56678641565e7f59dd5c6878f5bcbc842bf150a",
+        "created_on": "2013-02-01 09:59:32.126000000",
         "id": "fdda826a0815859ab48d22a05a43472f0f55f89a",
         "name": "MyGroup"
       },
@@ -1064,13 +1129,13 @@
   ]
 ----
 
-[[delete-group-member]]
-=== Delete Group Member
+[[remove-group-member]]
+=== Remove Group Member
 --
 'DELETE /groups/link:#group-id[\{group-id\}]/members/link:rest-api-accounts.html#account-id[\{account-id\}]'
 --
 
-Deletes a user from a Gerrit internal group.
+Removes a user from a Gerrit internal group.
 
 .Request
 ----
@@ -1082,15 +1147,15 @@
   HTTP/1.1 204 No Content
 ----
 
-[[delete-group-members]]
-=== Delete Group Members
+[[remove-group-members]]
+=== Remove Group Members
 --
 'POST /groups/link:#group-id[\{group-id\}]/members.delete'
 --
 
-Delete one or several users from a Gerrit internal group.
+Removes one or several users from a Gerrit internal group.
 
-The users to be deleted from the group must be provided in the request
+The users to be removed from the group must be provided in the request
 body as a link:#members-input[MembersInput] entity.
 
 .Request
@@ -1111,16 +1176,16 @@
   HTTP/1.1 204 No Content
 ----
 
-[[group-include-endpoints]]
-== Group Include Endpoints
+[[subgroup-endpoints]]
+== Subgroup Endpoints
 
-[[included-groups]]
-=== List Included Groups
+[[list-subgroups]]
+=== List Subgroups
 --
 'GET /groups/link:#group-id[\{group-id\}]/groups/'
 --
 
-Lists the directly included groups of a group.
+Lists the direct subgroups of a group.
 
 As result a list of link:#group-info[GroupInfo] entries is returned.
 The entries in the list are sorted by group name and UUID.
@@ -1146,18 +1211,19 @@
       },
       "group_id": 38,
       "owner": "MyProject-Verifiers",
-      "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc"
+      "owner_id": "7ca042f4d5847936fcb90ca91057673157fd06fc",
+      "created_on": "2013-02-01 09:59:32.126000000"
     }
   ]
 ----
 
-[[get-included-group]]
-=== Get Included Group
+[[get-subgroup]]
+=== Get Subgroup
 --
 'GET /groups/link:#group-id[\{group-id\}]/groups/link:#group-id[\{group-id\}]'
 --
 
-Retrieves an included group.
+Retrieves a subgroup.
 
 .Request
 ----
@@ -1165,7 +1231,7 @@
 ----
 
 As response a link:#group-info[GroupInfo] entity is returned that
-describes the included group.
+describes the subgroup.
 
 .Response
 ----
@@ -1182,17 +1248,18 @@
     },
     "group_id": 38,
     "owner": "Administrators",
-    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+    "created_on": "2013-02-01 09:59:32.126000000"
   }
 ----
 
-[[include-group]]
-=== Include Group
+[[add-subgroup]]
+=== Add Subgroup
 --
 'PUT /groups/link:#group-id[\{group-id\}]/groups/link:#group-id[\{group-id\}]'
 --
 
-Includes an internal or external group into a Gerrit internal group.
+Adds an internal or external group as subgroup to a Gerrit internal group.
 External groups must be specified using the UUID.
 
 .Request
@@ -1201,7 +1268,7 @@
 ----
 
 As response a link:#group-info[GroupInfo] entity is returned that
-describes the included group.
+describes the subgroup.
 
 .Response
 ----
@@ -1218,15 +1285,16 @@
     },
     "group_id": 8,
     "owner": "Administrators",
-    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+    "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+    "created_on": "2013-02-01 09:59:32.126000000"
   }
 ----
 
-The request also succeeds if the group is already included in this
-group, but then the HTTP response code is `200 OK`.
+The request also succeeds if the group is already a subgroup of this
+group.
 
-[[include-groups]]
-=== Include Groups
+[[add-subgroups]]
+=== Add Subgroups
 --
 'POST /groups/link:#group-id[\{group-id\}]/groups'
 --
@@ -1237,10 +1305,10 @@
 'POST /groups/link:#group-id[\{group-id\}]/groups.add'
 --
 
-Includes one or several groups into a Gerrit internal group.
+Adds one or several groups as subgroups to a Gerrit internal group.
 
-The groups to be included into the group must be provided in the
-request body as a link:#groups-input[GroupsInput] entity.
+The subgroups to be added must be provided in the request body as a
+link:#groups-input[GroupsInput] entity.
 
 .Request
 ----
@@ -1259,8 +1327,8 @@
 returned that describes the groups that were specified in the
 link:#groups-input[GroupsInput]. A link:#group-info[GroupInfo] entity
 is returned for each group specified in the input, independently of
-whether the group was newly included into the group or whether the
-group was already included in the group.
+whether the group was newly added as subgroup or whether the
+group was already a subgroup of the group.
 
 .Response
 ----
@@ -1278,7 +1346,8 @@
       },
       "group_id": 8,
       "owner": "Administrators",
-      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389"
+      "owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
+      "created_on": "2013-02-01 09:59:32.126000000"
     },
     {
       "id": "5057f3cbd3519d6ab69364429a89ffdffba50f73",
@@ -1288,18 +1357,19 @@
       },
       "group_id": 10,
       "owner": "MyOtherGroup",
-      "owner_id": "5057f3cbd3519d6ab69364429a89ffdffba50f73"
+      "owner_id": "5057f3cbd3519d6ab69364429a89ffdffba50f73",
+      "created_on": "2013-02-01 09:59:32.126000000"
     }
   ]
 ----
 
-[[delete-included-group]]
-=== Delete Included Group
+[[remove-subgroup]]
+=== Remove Subgroup
 --
 'DELETE /groups/link:#group-id[\{group-id\}]/groups/link:#group-id[\{group-id\}]'
 --
 
-Deletes an included group from a Gerrit internal group.
+Removes a subgroup from a Gerrit internal group.
 
 .Request
 ----
@@ -1311,16 +1381,16 @@
   HTTP/1.1 204 No Content
 ----
 
-[[delete-included-groups]]
-=== Delete Included Groups
+[[remove-subgroups]]
+=== Remove Subgroups
 --
 'POST /groups/link:#group-id[\{group-id\}]/groups.delete'
 --
 
-Delete one or several included groups from a Gerrit internal group.
+Removes one or several subgroups from a Gerrit internal group.
 
-The groups to be deleted from the group must be provided in the request
-body as a link:#groups-input[GroupsInput] entity.
+The subgroups to be removed must be provided in the request body as a
+link:#groups-input[GroupsInput] entity.
 
 .Request
 ----
@@ -1417,6 +1487,8 @@
 |`group_id`    |only for internal groups|The numeric ID of the group.
 |`owner`       |only for internal groups|The name of the owner group.
 |`owner_id`    |only for internal groups|The URL encoded UUID of the owner group.
+|`created_on`  |only for internal groups|The
+link:rest-api.html#timestamp[timestamp] of when the group was created.
 |`_more_groups`|optional, only for internal groups, not set if `false`|
 Whether the query would deliver more results if not limited. +
 Only set on the last group that is returned by a
@@ -1426,9 +1498,9 @@
 entities describing the direct members. +
 Only set if link:#members[members] are requested.
 |`includes`    |optional, only for internal groups|
-A list of link:#group-info[GroupInfo] entities describing the directly
-included groups. +
-Only set if link:#includes[included groups] are requested.
+A list of link:#group-info[GroupInfo] entities describing the direct
+subgroups. +
+Only set if link:#includes[subgroups] are requested.
 |===========================
 
 The type of a group can be deduced from the group's UUID:
diff --git a/Documentation/rest-api-plugins.txt b/Documentation/rest-api-plugins.txt
index 53f4bb5..938d101 100644
--- a/Documentation/rest-api-plugins.txt
+++ b/Documentation/rest-api-plugins.txt
@@ -47,6 +47,7 @@
     "delete-project": {
       "id": "delete-project",
       "index_url": "plugins/delete-project/",
+      "filename": "delete-project.jar",
       "version": "2.9-SNAPSHOT"
     }
   }
@@ -73,17 +74,175 @@
     "delete-project": {
       "id": "delete-project",
       "index_url": "plugins/delete-project/",
+      "filename": "delete-project.jar",
       "version": "2.9-SNAPSHOT"
     },
     "reviewers-by-blame": {
       "id": "reviewers-by-blame",
       "index_url": "plugins/reviewers-by-blame/",
+      "filename": "reviewers-by-blame.jar",
       "version": "2.9-SNAPSHOT",
       "disabled": true
     }
   }
 ----
 
+Limit(n)::
+Limit the number of plugins to be included in the results.
++
+Query the first plugin in the plugin list:
++
+.Request
+----
+  GET /plugins/?n=1 HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "delete-project": {
+      "id": "delete-project",
+      "index_url": "plugins/delete-project/",
+      "filename": "delete-project.jar",
+      "version": "2.9-SNAPSHOT"
+    }
+  }
+----
+
+Prefix(p)::
+Limit the results to those plugins that start with the specified
+prefix.
++
+The match is case sensitive. May not be used together with `m` or `r`.
++
+List all plugins that start with `delete`:
++
+.Request
+----
+  GET /plugins/?p=delete HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "delete-project": {
+      "id": "delete-project",
+      "index_url": "plugins/delete-project/",
+      "filename": "delete-project.jar",
+      "version": "2.9-SNAPSHOT"
+    }
+  }
+----
++
+E.g. this feature can be used by suggestion client UI's to limit results.
+
+Regex(r)::
+Limit the results to those plugins that match the specified regex.
++
+Boundary matchers '^' and '$' are implicit. For example: the regex 'test.*' will
+match any plugins that start with 'test' and regex '.*test' will match any
+project that end with 'test'.
++
+The match is case sensitive. May not be used together with `m` or `p`.
++
+List all plugins that match regex `some.*plugin`:
++
+.Request
+----
+  GET /plugins/?r=some.*plugin HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "some-plugin": {
+      "id": "some-plugin",
+      "index_url": "plugins/some-plugin/",
+      "filename": "some-plugin.jar",
+      "version": "2.9-SNAPSHOT"
+    },
+    "some-other-plugin": {
+      "id": "some-other-plugin",
+      "index_url": "plugins/some-other-plugin/",
+      "filename": "some-other-plugin.jar",
+      "version": "2.9-SNAPSHOT"
+    }
+  }
+
+----
+
+Skip(S)::
+Skip the given number of plugins from the beginning of the list.
++
+Query the second plugin in the plugin list:
++
+.Request
+----
+  GET /plugins/?all&n=1&S=1 HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "reviewers-by-blame": {
+      "id": "reviewers-by-blame",
+      "index_url": "plugins/reviewers-by-blame/",
+      "filename": "reviewers-by-blame.jar",
+      "version": "2.9-SNAPSHOT",
+      "disabled": true
+    }
+  }
+----
+
+Substring(m)::
+Limit the results to those plugins that match the specified substring.
++
+The match is case insensitive. May not be used together with `r` or `p`.
++
+List all plugins that match substring `project`:
++
+.Request
+----
+  GET /plugins/?m=project HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "delete-project": {
+      "id": "delete-project",
+      "index_url": "plugins/delete-project/",
+      "filename": "delete-project.jar",
+      "version": "2.9-SNAPSHOT"
+    }
+  }
+----
+
 [[install-plugin]]
 === Install Plugin
 --
@@ -279,6 +438,7 @@
 |`id`       ||The ID of the plugin.
 |`version`  ||The version of the plugin.
 |`index_url`|optional|URL of the plugin's default page.
+|`filename` |optional|The plugin's filename.
 |`disabled` |not set if `false`|Whether the plugin is disabled.
 |=======================
 
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 3d53130..27d933f 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -379,7 +379,16 @@
     "name": "plugins/replication",
     "parent": "Public-Plugins",
     "description": "Copies to other servers using the Git protocol",
-    "state": "ACTIVE"
+    "state": "ACTIVE",
+    "labels": {
+      "Code-Review": {
+        "values": {
+          " 0": "No score",
+          "+1": "Approved"
+        },
+        "default_value": 0
+      }
+    }
   }
 ----
 
@@ -422,7 +431,16 @@
     "id": "MyProject",
     "name": "MyProject",
     "parent": "All-Projects",
-    "description": "This is a demo project."
+    "description": "This is a demo project.",
+    "labels": {
+      "Code-Review": {
+        "values": {
+          " 0": "No score",
+          "+1": "Approved"
+        },
+        "default_value": 0
+      }
+    }
   }
 ----
 
@@ -970,7 +988,9 @@
 
 Lists the access rights for a single project.
 
-As result a link:#project-access-info[ProjectAccessInfo] entity is returned.
+As result a
+link:rest-api-access.html#project-access-info[ProjectAccessInfo]
+entity is returned.
 
 .Request
 ----
@@ -1015,7 +1035,23 @@
     "can_upload": true,
     "can_add": true,
     "can_add_tags": true,
-    "config_visible": true
+    "config_visible": true,
+    "groups": {
+      "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
+        "url": "#/admin/groups/uuid-c2ce4749a32ceb82cd6adcce65b8216e12afb41c",
+        "options": {},
+        "description": "Users who perform batch actions on Gerrit",
+        "group_id": 2,
+        "owner": "Administrators",
+        "owner_id": "d5b7124af4de52924ed397913e2c3b37bf186948",
+        "created_on": "2009-06-08 23:31:00.000000000",
+        "name": "Non-Interactive Users"
+      },
+      "global:Anonymous-Users": {
+        "options": {},
+        "name": "Anonymous Users"
+      }
+    }
   }
 ----
 
@@ -1039,7 +1075,9 @@
 
 After removals have been applied, additions will be applied.
 
-As result a link:#project-access-info[ProjectAccessInfo] entity is returned.
+As result a
+link:rest-api-access.html#project-access-info[ProjectAccessInfo]
+entity is returned.
 
 .Request
 ----
@@ -1047,21 +1085,19 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "remove": [
-      {
-        "refs/*": {
-          "permissions": {
-            "read": {
-              "rules": {
-                "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
-                  "action": "ALLOW"
-                }
+    "remove": {
+      "refs/*": {
+        "permissions": {
+          "read": {
+            "rules": {
+              "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
+                "action": "ALLOW"
               }
             }
           }
         }
       }
-    ]
+    }
   }
 ----
 
@@ -1099,7 +1135,119 @@
     "can_upload": true,
     "can_add": true,
     "can_add_tags": true,
-    "config_visible": true
+    "config_visible": true,
+    "groups": {
+      "global:Anonymous-Users": {
+        "options": {},
+        "name": "Anonymous Users"
+      }
+    }
+  }
+----
+
+[[create-access-change]]
+=== Create Access Rights Change for review.
+--
+'PUT /projects/link:rest-api-projects.html#project-name[\{project-name\}]/access:review
+--
+
+Sets access rights for the project using the diff schema provided by
+link:#project-access-input[ProjectAccessInput]
+
+This takes the same input as link:#set-access[Update Access Rights], but creates a pending
+change for review. Like link:#create-change[Create Change], it returns
+a link:#change-info[ChangeInfo] entity describing the resulting change.
+
+.Request
+----
+  PUT /projects/MyProject/access:review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "add":{
+      "refs/heads/*":{
+        "permissions":{
+          "read":{
+            "rules":{
+              "global:Anonymous-Users": {
+                "action":"DENY",
+                "force":false
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "project": "testproj",
+    "branch": "refs/meta/config",
+    "hashtags": [],
+    "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+    "subject": "Review access change",
+    "status": "NEW",
+    "created": "2017-09-07 14:31:11.852000000",
+    "updated": "2017-09-07 14:31:11.852000000",
+    "submit_type": "CHERRY_PICK",
+    "mergeable": true,
+    "insertions": 2,
+    "deletions": 0,
+    "unresolved_comment_count": 0,
+    "has_review_started": true,
+    "_number": 7,
+    "owner": {
+      "_account_id": 1000000
+    }
+  }
+----
+
+[[check-access]]
+=== Check Access
+--
+'POST /projects/MyProject/check.access'
+--
+
+Runs access checks for other users. This requires the
+link:access-control.html#capability_administrateServer[Administrate Server]
+global capability.
+
+Input for the access checks that should be run must be provided in
+the request body inside a
+link:#access-check-input[AccessCheckInput] entity.
+
+.Request
+----
+  POST /projects/MyProject/check.access HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "account": "Kristen.Burns@gerritcodereview.com",
+    "ref": "refs/heads/secret/bla"
+  }
+----
+
+The result is a link:#access-check-info[AccessCheckInfo] entity
+detailing the read access of the given user for the given project (or
+project-ref combination).
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "message": "user Kristen Burns \u003cKristen.Burns@gerritcodereview.com\u003e (1000098) cannot see ref refs/heads/secret/bla in project MyProject",
+    "status": 403
   }
 ----
 
@@ -1190,7 +1338,6 @@
     {
       "ref": "HEAD",
       "revision": "master",
-      "can_delete": false
     }
   ]
 ----
@@ -1214,7 +1361,6 @@
     {
       "ref": "HEAD",
       "revision": "master",
-      "can_delete": false
     }
   ]
 ----
@@ -2182,6 +2328,60 @@
   Ly8gQ29weXJpZ2h0IChDKSAyMDEwIFRoZSBBbmRyb2lkIE9wZW4gU291cmNlIFByb2plY...
 ----
 
+
+[[cherry-pick-commit]]
+=== Cherry Pick Commit
+--
+'POST /projects/link:#project-name[\{project-name\}]/commits/link:#commit-id[\{commit-id\}]/cherrypick'
+--
+
+Cherry-picks a commit of a project to a destination branch.
+
+The destination branch must be provided in the request body inside a
+link:rest-api-changes.html#cherrypick-input[CherryPickInput] entity.
+If the commit message is not set, the commit message of the source
+commit will be used.
+
+.Request
+----
+  POST /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/cherrypick HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message" : "Implementing Feature X",
+    "destination" : "release-branch"
+  }
+----
+
+As response a link:rest-api-changes.html#change-info[ChangeInfo] entity is returned that
+describes the resulting cherry-picked change.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "project": "myProject",
+    "branch": "release-branch",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "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": 12,
+    "deletions": 11,
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    }
+  }
+----
+
 [[dashboard-endpoints]]
 == Dashboard Endpoints
 
@@ -2217,7 +2417,7 @@
       "path": "closed",
       "description": "Merged and abandoned changes in last 7 weeks",
       "url": "/dashboard/?title\u003dClosed+changes\u0026Merged\u003dstatus:merged+age:7w\u0026Abandoned\u003dstatus:abandoned+age:7w",
-      "default": true,
+      "is_default": true,
       "title": "Closed changes",
       "sections": [
         {
@@ -2268,7 +2468,7 @@
     "path": "closed",
     "description": "Merged and abandoned changes in last 7 weeks",
     "url": "/dashboard/?title\u003dClosed+changes\u0026Merged\u003dstatus:merged+age:7w\u0026Abandoned\u003dstatus:abandoned+age:7w",
-    "default": true,
+    "is_default": true,
     "title": "Closed changes",
     "sections": [
       {
@@ -2304,7 +2504,7 @@
     "path": "closed",
     "description": "Merged and abandoned changes in last 7 weeks",
     "url": "/dashboard/?title\u003dClosed+changes\u0026Merged\u003dstatus:merged+age:7w\u0026Abandoned\u003dstatus:abandoned+age:7w",
-    "default": true,
+    "is_default": true,
     "title": "Closed changes",
     "sections": [
       {
@@ -2359,7 +2559,7 @@
     "path": "closed",
     "description": "Merged and abandoned changes in last 7 weeks",
     "url": "/dashboard/?title\u003dClosed+changes\u0026Merged\u003dstatus:merged+age:7w\u0026Abandoned\u003dstatus:abandoned+age:7w",
-    "default": true,
+    "is_default": true,
     "title": "Closed changes",
     "sections": [
       {
@@ -2434,6 +2634,31 @@
 [[json-entities]]
 == JSON Entities
 
+[[access-check-info]]
+=== AccessCheckInfo
+
+The `AccessCheckInfo` entity is the result of an access check.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`status`                    ||The HTTP status code for the access.
+200 means success, 403 means denied and 404 means the project does not exist.
+|`message`                   |optional|A clarifying message if `status` is not 200.
+|=========================================
+
+[[access-check-input]]
+=== AccessCheckInput
+The `AccessCheckInput` entity is either an account or
+(account, ref) tuple for which we want to check access.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`account`                   ||The account for which to check access
+|`ref`                       |optional|The refname for which to check access
+|=========================================
+
 [[ban-input]]
 === BanInput
 The `BanInput` entity contains information for banning commits in a
@@ -2467,7 +2692,7 @@
 |Field Name  ||Description
 |`ref`       ||The ref of the branch.
 |`revision`  ||The revision to which the branch points.
-|`can_delete`|`false` if not set|
+|`can_delete`|not set if `false`|
 Whether the calling user can delete this branch.
 |`web_links` |optional|
 Links to the branch in external sites as a list of
@@ -2532,14 +2757,23 @@
 |`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`     ||
+|`private_by_default`         ||
+link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
+all new changes are set as private by default.
+|`work_in_progress_by_default`||
+link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
+all new changes are set as work-in-progress by default.
+|`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[
 MaxObjectSizeLimitInfo] entity.
-|`submit_type`               ||
+|`submit_type`                ||
 The default submit type of the project, can be `MERGE_IF_NECESSARY`,
 `FAST_FORWARD_ONLY`, `REBASE_IF_NECESSARY`, `REBASE_ALWAYS`, `MERGE_ALWAYS` or
 `CHERRY_PICK`.
+|`match_author_to_committer_date` |optional|
+link:#inherited-boolean-info[InheritedBooleanInfo] that indicates whether
+a change's author date will be changed to match its submitter date upon submit.
 |`state`                     |optional|
 The state of the project, can be `ACTIVE`, `READ_ONLY` or `HIDDEN`. +
 Not set if the project state is `ACTIVE`.
@@ -2694,7 +2928,7 @@
 The URL under which the dashboard can be opened in the Gerrit Web UI. +
 The URL is relative to the canonical web URL. +
 Tokens in the queries such as `${project}` are resolved.
-|`default`         |not set if `false`|
+|`is_default`      |not set if `false`|
 Whether this is the default dashboard of the project.
 |`title`           |optional|The title of the dashboard.
 |`sections`        ||
@@ -2797,6 +3031,19 @@
 Not set if there is no parent.
 |================================
 
+
+[[label-type-info]]
+=== LabelTypeInfo
+The `LabelTypeInfo` entity contains metadata about the labels that a
+project has.
+
+[options="header",cols="1,^2,4"]
+|================================
+|Field Name         ||Description
+|`values`           ||Map of the available values to their description.
+|`default_value`    ||The default value of this label.
+|================================
+
 [[max-object-size-limit-info]]
 === MaxObjectSizeLimitInfo
 The `MaxObjectSizeLimitInfo` entity contains information about the
@@ -2875,6 +3122,11 @@
 |`description` |optional|The description of the project.
 |`state`       |optional|`ACTIVE`, `READ_ONLY` or `HIDDEN`.
 |`branches`    |optional|Map of branch names to HEAD revisions.
+|`labels`      |optional|
+Map of label names to
+link:#label-type-info[LabelTypeInfo] entries.
+This field is filled for link:#create-project[Create Project] and
+link:#get-project[Get Project] calls.
 |`web_links`   |optional|
 Links to the project in external sites as a list of
 link:rest-api-changes.html#web-link-info[WebLinkInfo] entries.
@@ -2933,6 +3185,12 @@
 |`require_change_id`                           |`INHERIT` if not set|
 Whether the usage of Change-Ids is required for the project (`TRUE`,
 `FALSE`, `INHERIT`).
+|`enable_signed_push`                           |`INHERIT` if not set|
+Whether signed push validation is enabled on the project  (`TRUE`,
+`FALSE`, `INHERIT`).
+|`require_signed_push`                          |`INHERIT` if not set|
+Whether signed push validation is required on the project  (`TRUE`,
+`FALSE`, `INHERIT`).
 |`max_object_size_limit`     |optional|
 Max allowed Git object size for this project.
 Common unit suffixes of 'k', 'm', or 'g' are supported.
@@ -3003,7 +3261,7 @@
 the signature.
 |`tagger`|Only set for annotated tags, if present in the tag.|The tagger as a
 link:rest-api-changes.html#git-person-info[GitPersonInfo] entity.
-|`can_delete`|`false` if not set|
+|`can_delete`|not set if `false`|
 Whether the calling user can delete this tag.
 |`web_links` |optional|
 Links to the tag in external sites as a list of
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 7928512..0957d32 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -32,12 +32,41 @@
 results to correspond to what anonymous users can read (which may
 be nothing at all).
 
-Users (and programs) may authenticate by prefixing the endpoint URL with
-`/a/`. For example to authenticate to `/projects/`, request the URL
-`/a/projects/`.
+Users (and programs) can authenticate with HTTP passwords by prefixing
+the endpoint URL with `/a/`. For example to authenticate to
+`/projects/`, request the URL `/a/projects/`. Gerrit will use HTTP basic
+authentication with the HTTP password from the user's account settings
+page. This form of authentication bypasses the need for XSRF tokens.
 
-Gerrit uses HTTP basic authentication with the HTTP password from the
-user's account settings page.
+An authorization cookie may be presented in the request URL inside the
+`access_token` query parameter.  XSRF tokens are not required when a
+valid `access_token` is used in the URL.
+
+[[cors]]
+=== CORS
+
+Cross-site scripting may be supported if the administrator has configured
+link:config-gerrit.html#site.allowOriginRegex[site.allowOriginRegex].
+
+Approved web applications running from an allowed origin can rely on
+CORS preflight to authorize requests requiring cookie based
+authentication, or mutations (POST, PUT, DELETE). Mutations require a
+valid XSRF token in the `X-Gerrit-Auth` request header.
+
+Alternatively applications can use `access_token` in the URL (see
+above) to authorize requests. Mutations sent as POST with a request
+content type of `text/plain` can skip CORS preflight. Gerrit accepts
+additional query parameters `$m` to override the correct method (PUT,
+POST, DELETE) and `$ct` to specify the actual content type, such as
+`application/json; charset=UTF-8`. Example:
+
+----
+    POST /changes/42/topic?$m=PUT&$ct=application/json%3B%20charset%3DUTF-8&access_token=secret HTTP/1.1
+	Content-Type: text/plain
+	Content-Length: 23
+
+	{"topic": "new-topic"}
+----
 
 [[preconditions]]
 === Preconditions
@@ -78,6 +107,12 @@
 `Accept-Encoding` request header is set to `gzip`. This may
 save on network transfer time for larger responses.
 
+[[input]]
+=== Input Format
+Unknown JSON parameters will simply be ignored by Gerrit without causing
+an exception. This also applies to case-sensitive parameters, such as
+map keys.
+
 [[timestamp]]
 === Timestamp
 Timestamps are given in UTC and have the format
diff --git a/Documentation/user-changeid.txt b/Documentation/user-changeid.txt
index 44ca6e0..f965db7 100644
--- a/Documentation/user-changeid.txt
+++ b/Documentation/user-changeid.txt
@@ -70,10 +70,10 @@
 Change Upload
 --------------
 
-During upload by pushing to `+refs/for/*+`, `+refs/drafts/*+` or
-`+refs/heads/*+`, Gerrit will try to find an existing review the
-uploaded commit relates to. For an existing review to match, the
-following properties have to match:
+During upload by pushing to `+refs/for/*+` or `+refs/heads/*+`,
+Gerrit will try to find an existing review the uploaded commit
+relates to. For an existing review to match, the following properties
+have to match:
 
 * Change-Id
 * Repository name
@@ -104,7 +104,7 @@
 By default, Gerrit will prevent pushing for review if no Change-Id is provided,
 with the following message:
 
-  ! [remote rejected] HEAD -> refs/publish/master (missing Change-Id in commit
+  ! [remote rejected] HEAD -> refs/for/master (missing Change-Id in commit
   message footer)
 
 However, repositories can be configured to allow commits without Change-Ids
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index f3c8b00..bce8183 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -10,9 +10,8 @@
 A new change can be created directly in the browser, meaning it is not necessary
 to clone the whole repository to make trivial changes.
 
-The new change is created as a draft change, unless
-link:config-gerrit.html#change.allowDrafts[change.allowDrafts] is set to false,
-in which case the change is created as a normal new change.
+The new change is created as a public
+link:user-upload.html#wip[work-in-progress change].
 
 There are two different ways to create a new change:
 
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 4dc4880..4b928f3 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -26,8 +26,8 @@
 the change owner.
 
 Notification mails for comments added on changes are not sent to the user
-who added the comment unless the user has enabled the 'CC Me On Comments I
-Write' option in the user preferences.
+who added the comment unless the user has enabled the 'Every comment'
+option in the user preferences.
 
 
 [[project]]
@@ -142,6 +142,108 @@
 access is automatically checked by Gerrit and therefore does not
 need to use the `visibleto:` operator in the filter.
 
+[[footers]]
+== Email Footers
+
+Notification emails related to changes include metadata about the change
+to support writing mail filters. This metadata is included in the form
+of footers in the message content. For HTML emails, these footers are
+hidden, but they can be examined by viewing the HTML source of messages.
+
+In this way users may apply filters and rules to their incoming Gerrit
+notifications using the values of these footers. For example a Gmail
+filter to find emails regarding reviews that you are a reviewer of might
+take the following form.
+
+----
+  "Gerrit-Reviewer: Your Name <your.email@example.com>"
+----
+
+[[Gerrit-MessageType]]Gerrit-MessageType::
+
+The message type footer states the type of the message and will take one
+of the following values.
+
+* abandon
+* comment
+* deleteReviewer
+* deleteVote
+* merged
+* newchange
+* newpatchset
+* restore
+* revert
+* setassignee
+
+[[Gerrit-Change-Id]]Gerrit-Change-Id::
+
+The change ID footer states the ID of the change, such as
+`I3443af49fcdc16ca941ee7cf2b5e33c1106f3b1d`.
+
+[[Gerrit-Change-Number]]Gerrit-Change-Number::
+
+The change number footer states the numeric ID of the change, for
+example `92191`.
+
+[[Gerrit-PatchSet]]Gerrit-PatchSet::
+
+The patch set footer states the number of the patch set that the email
+relates to. For example, a notification email for a vote being set on
+the seventh patch set will take a value of `7`.
+
+[[Gerrit-Owner]]Gerrit-Owner::
+
+The owner footer states the name and email address of the change's
+owner. For example, `Owner Name <owner@example.com>`.
+
+[[Gerrit-Reviewer]]Gerrit-Reviewer::
+
+The reviewer footers list the names and email addresses of the change's
+reviewrs. One footer is included for each reviewer. For example, if a
+change has two reviewers, the footers might include:
+
+----
+  Gerrit-Reviewer: Reviewer One <one@example.com>
+  Gerrit-Reviewer: Reviewer Two <two@example.com>
+----
+
+[[Gerrit-CC]]Gerrit-CC::
+
+The CC footers list the names and email addresses of those who have been
+CC'd on the change. One footer is included for each reviewer. For
+example, if a change CCs two users, the footers might include:
+
+----
+  Gerrit-CC: User One <one@example.com>
+  Gerrit-CC: User Two <two@example.com>
+----
+
+[[Gerrit-Project]]Gerrit-Project::
+
+The project footer states the project to which the change belongs.
+
+[[Gerrit-Branch]]Gerrit-Branch::
+
+The branch footer states the abbreviated name of the branch that the
+change targets.
+
+[[Gerrit-Comment-Date]]Gerrit-Comment-Date::
+
+In comment emails, the comment date footer states the date that the
+comment was posted.
+
+[[Gerrit-HasComments]]Gerrit-HasComments::
+
+In comment emails, the has-comments footer states whether inline
+comments had been posted in that notification using "Yes" or "No", for
+example `Gerrit-HasComments: Yes`.
+
+[[Gerrit-HasLabels]]Gerrit-HasLabels::
+
+In comment emails, the has-labels footer states whether label votes had
+been posted in that notification using "Yes" or "No", for
+example `Gerrit-HasLabels: No`.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 1ddaed0..99ce645 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -61,13 +61,6 @@
 +
 The change was abandoned.
 
-- [[draft]]`Draft`:
-+
-The change is a draft that is only visible to the change owner, the
-reviewers that were explicitly added to the change, and users who have
-the link:access-control.html#category_view_drafts[View Drafts] global
-capability assigned.
-
 [[commit-info]]
 === Commit Info Block
 
@@ -258,30 +251,16 @@
 Users can only cherry-pick changes to branches for which they are
 allowed to upload changes for review.
 
-** [[publish]]`Publish`:
-+
-Publishes the currently viewed draft patch set. If this is the first
-patch set of a change that is published, the change will be published
-as well.
-+
-The `Publish` button is only available if a draft patch set is viewed
-and the user is the change owner or has the
-link:access-control.html#category_publish_drafts[Publish Drafts] access
-right assigned.
-
 ** [[delete]]`Delete Change` / `Delete Revision`:
 +
-Deletes the change / the currently viewed draft patch set.
+Deletes the change.
 +
 For open or abandoned changes, the `Delete Change` button will be available
 and if the user is the change owner and is granted the
 link:access-control.html#category_delete_own_changes[Delete Own Changes]
 permission, if they are granted the
 link:access-control.html#category_delete_changes[Delete Changes] permission,
-or if they are an administrator. For draft changes,
-the `Delete Change` / `Delete Revision` buttons will be available if the user is
-the change owner or has the
-link:access-control.html#category_delete_drafts[Delete Drafts] access right assigned.
+or if they are an administrator.
 
 ** [[plugin-actions]]Further actions may be available if plugins are installed.
 
@@ -445,7 +424,7 @@
 
 The available download commands depend on the installed Gerrit plugins.
 The most popular plugin for download commands, the
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/download-commands[
+link:https://gerrit-review.googlesource.com/admin/repos/plugins/download-commands[
 download-commands] plugin, provides commands to checkout, pull and
 cherry-pick a patch set.
 
diff --git a/Documentation/user-search-groups.txt b/Documentation/user-search-groups.txt
index fccad65..6fa8dbb 100644
--- a/Documentation/user-search-groups.txt
+++ b/Documentation/user-search-groups.txt
@@ -59,6 +59,17 @@
 +
 Matches groups that have the UUID 'UUID'.
 
+[[member]]
+member:'MEMBER'::
++
+Matches groups that have the account represented by 'MEMBER' as a member.
+
+[[subgroup]]
+subgroup:'SUBGROUP'::
++
+Matches groups that have a subgroup whose name best matches 'SUBGROUP' or
+whose UUID is 'SUBGROUP'.
+
 == Magical Operators
 
 [[is-visible]]
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 4207e3f..21875b2 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -12,7 +12,6 @@
 |All > Open           | status:open '(or is:open)'
 |All > Merged         | status:merged
 |All > Abandoned      | status:abandoned
-|My > Drafts          | owner:self is:draft
 |My > Watched Changes | is:watched is:open
 |My > Starred Changes | is:starred
 |My > Draft Comments  | has:draft
@@ -134,6 +133,11 @@
 Changes that have the given user CC'ed on them. The special case of `cc:self`
 will find changes where the caller has been CC'ed.
 
+[[revertof]]
+revertof:'ID'::
++
+Changes that revert the change specified by the numeric 'ID'.
+
 [[reviewerin]]
 reviewerin:'GROUP'::
 +
@@ -194,6 +198,12 @@
 often combined with 'branch:' and 'project:' operators to select
 all related changes in a series.
 
+[[hashtag]]
+hashtag:'HASHTAG'::
++
+Changes whose link:intro-user.html#hashtags[hashtag] matches 'HASHTAG'.
+The match is case-insensitive.
+
 [[ref]]
 ref:'REF'::
 +
@@ -344,10 +354,6 @@
 +
 True if the change is open.
 
-is:draft::
-+
-True if the change is a draft.
-
 is:closed::
 +
 True if the change is either merged or abandoned.
@@ -379,6 +385,22 @@
 Mergeability of abandoned changes is not computed. This operator will
 not find any abandoned but mergeable changes.
 
+[[ignored]]
+is:ignored::
++
+True if the change is ignored. Same as `star:ignore`.
+
+[[private]]
+is:private::
++
+True if the change is private, ie. only visible to owner and its
+reviewers.
+
+[[workInProgress]]
+is:wip::
++
+True if the change is Work In Progress.
+
 [[status]]
 status:open, status:pending::
 +
@@ -521,7 +543,7 @@
 
 A label name must be followed by either a score with optional operator,
 or a label status. The easiest way to explain this is by example.
-+
+
 First, some examples of scores with operators:
 
 `label:Code-Review=2`::
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 25ab3ca..1e76df5 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -243,12 +243,11 @@
 [[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 or add it with the command line flag
-`--push-option`, aliased to `-o`. In this example the short topic
-tag 'driver/i42' will be saved on each change this push creates or
-updates:
+To include a short link:intro-user.html#topics[topic] associated with all
+of the changes in the same group, such as the local topic branch name,
+append it after the destination branch name or add it with the command line
+flag `--push-option`, aliased to `-o`. In this example the short topic name
+'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
@@ -257,6 +256,64 @@
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental -o topic=driver/i42
 ----
 
+[[hashtag]]
+==== Hashtag
+
+To include a link:intro-user.html#hashtags[hashtag] associated with all of the
+changes in the same group, use the `hashtag` or `t` option:
+
+----
+  // these are all equivalent
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%hashtag=stable-fix
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%t=stable-fix
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental -o hashtag=stable-fix
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental -o t=stable-fix
+----
+
+[[private]]
+==== Private Changes
+
+To push a private change or to turn a change private on push the `private`
+option can be specified:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%private
+----
+
+Omitting the `private` option when pushing updates to a private change
+doesn't make change non-private again. To remove the private
+flag from a change on push, explicitly specify the `remove-private` option:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%remove-private
+----
+
+[[wip]]
+==== Work-In-Progress Changes
+
+To push a wip change or to turn a change to wip the `work-in-progress` (or `wip`)
+option can be specified:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%wip
+----
+
+Omitting the `wip` option when pushing updates to a wip change
+doesn't make change ready again. To remove the `wip`
+flag from a change on push, explicitly specify the `ready` option:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%ready
+----
+
+Only change owners, project owners and site administrators can specify
+`work-in-progress` and `ready` options on push.
+
+The default for this option can be set as a
+link:intro-user.html#work-in-progress-by-default[user preference]. If the
+preference is set so the default behavior is to create `work-in-progress`
+changes, this can be overridden with the `ready` option.
+
 [[message]]
 ==== Message
 
@@ -264,12 +321,35 @@
 option:
 
 ----
-  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%m=This_is_a_rebase_on_master
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%m=This_is_a_rebase_on_master%21
 ----
 
 [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".
+git push refs parameter does not allow spaces. Use the '_' or '+' character
+to represent spaces, and percent-encoding to represent other special chars.
+The above example will thus be applied as "This is a rebase on master!"
+
+To avoid confusion in parsing the git ref, at least the following characters
+must be percent-encoded: " %^@.~-+_:/!". Note that some of the reserved
+characters (like tilde) are not escaped in the standard URL encoding rules,
+so a language-provided function (e.g. encodeURIComponent(), in javascript)
+might not suffice. To be safest, you might consider percent-encoding all
+non-alphanumeric characters (and all multibyte UTF-8 code points).
+
+[[publish-comments]]
+==== Publish Draft Comments
+
+If you have draft comments on the change(s) that are updated by the push, the
+`publish-comments` option will cause them to be published:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%publish-comments
+----
+
+The default for this option can be set as a
+link:intro-user.html#publish-comments-on-push[user preference]. If the
+preference is set so the default behavior is to publish, this can be overridden
+with the `no-publish-comments` (or `np`) option.
 
 [[review_labels]]
 ==== Review Labels
@@ -466,6 +546,44 @@
 make undesired changes to the public repository.
 
 
+[[skip_validation]]
+=== Skip Validation
+
+Even when a user has permission to push directly to a branch
+link:#bypass_review[bypassing review], by default Gerrit will still validate any
+new commits, for example to check author/committer identities, and run
+link:config-validation.html#new-commit-validation[validation plugins]. This
+behavior can be bypassed with a push option:
+
+----
+git push -o skip-validation HEAD:master
+----
+
+Using the `skip-validation` option requires the user to have a specific set
+of permissions, *in addition* to those permissions already required to bypass
+review:
+
+* link:access-control.html#category_forge_author[Forge Author]
+* link:access-control.html#category_forge_committer[Forge Committer]
+* link:access-control.html#category_forge_server[Forge Server]
+* link:access-control.html#category_push_merge[Push Merge Commits]
+
+Plus these additional requirements on the project:
+
+* Project must not link:project-configuration.html#require-signed-off-by[require
+Signed-off-by].
+* Project must not have `refs/meta/reject-commits`.
+
+This option only applies when pushing directly to a branch bypassing review.
+Validation also occurs when pushing new changes for review, and that type of
+validation cannot be skipped.
+
+The `skip-validation` option is always required when pushing
+link:error-too-many-commits.html[more than a certain number of commits]. This is
+the recommended approach when pushing lots of old history, since some validators
+would require rewriting history in order to make them pass.
+
+
 [[auto_merge]]
 === Auto-Merge during Push
 
diff --git a/README.md b/README.md
index 78c8477..da891ea 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
 ## Source
 
 Our canonical Git repository is located on [googlesource.com](https://gerrit.googlesource.com/gerrit).
-There is a mirror of the repository on [Github](https://github.com/gerrit-review/gerrit).
+There is a mirror of the repository on [Github](https://github.com/GerritCodeReview/gerrit).
 
 ## Reporting bugs
 
@@ -39,9 +39,6 @@
 
 ## Getting in contact
 
-The IRC channel on freenode is #gerrit. An archive is available at:
-[echelog.com](http://echelog.com/logs/browse/gerrit).
-
 The Developer Mailing list is [repo-discuss on Google Groups](https://groups.google.com/forum/#!forum/repo-discuss).
 
 ## License
diff --git a/WORKSPACE b/WORKSPACE
index d477373..fff3bfb 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -247,8 +247,8 @@
 
 maven_jar(
     name = "joda-time",
-    artifact = "joda-time:joda-time:2.9.4",
-    sha1 = "1c295b462f16702ebe720bbb08f62e1ba80da41b",
+    artifact = "joda-time:joda-time:2.9.9",
+    sha1 = "f7b520c458572890807d143670c9b24f4de90897",
 )
 
 maven_jar(
@@ -285,8 +285,8 @@
 
 maven_jar(
     name = "juniversalchardet",
-    artifact = "com.googlecode.juniversalchardet:juniversalchardet:1.0.3",
-    sha1 = "cd49678784c46aa8789c060538e0154013bb421b",
+    artifact = "com.github.albfernandez:juniversalchardet:2.0.0",
+    sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
 )
 
 SLF4J_VERS = "1.7.26"
@@ -347,8 +347,8 @@
 
 maven_jar(
     name = "commons-codec",
-    artifact = "commons-codec:commons-codec:1.4",
-    sha1 = "4216af16d38465bbab0f3dff8efa14204f7a399a",
+    artifact = "commons-codec:commons-codec:1.10",
+    sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
 )
 
 maven_jar(
@@ -359,8 +359,8 @@
 
 maven_jar(
     name = "commons-compress",
-    artifact = "org.apache.commons:commons-compress:1.12",
-    sha1 = "84caa68576e345eb5e7ae61a0e5a9229eb100d7b",
+    artifact = "org.apache.commons:commons-compress:1.13",
+    sha1 = "15c5e9584200122924e50203ae210b57616b75ee",
 )
 
 maven_jar(
@@ -395,8 +395,8 @@
 
 maven_jar(
     name = "commons-validator",
-    artifact = "commons-validator:commons-validator:1.5.1",
-    sha1 = "86d05a46e8f064b300657f751b5a98c62807e2a0",
+    artifact = "commons-validator:commons-validator:1.6",
+    sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
 )
 
 maven_jar(
@@ -407,8 +407,8 @@
 
 maven_jar(
     name = "pegdown",
-    artifact = "org.pegdown:pegdown:1.4.2",
-    sha1 = "d96db502ed832df867ff5d918f05b51ba3879ea7",
+    artifact = "org.pegdown:pegdown:1.6.0",
+    sha1 = "231ae49d913467deb2027d0b8a0b68b231deef4f",
 )
 
 maven_jar(
@@ -600,17 +600,17 @@
 
 maven_jar(
     name = "blame-cache",
-    artifact = "com/google/gitiles:blame-cache:0.2-1",
+    artifact = "com/google/gitiles:blame-cache:0.2-5",
     attach_source = False,
     repository = GERRIT,
-    sha1 = "da7977e8b140b63f18054214c1d1b86ffa6896cb",
+    sha1 = "50861b114350c598579ba66f99285e692e3c8d45",
 )
 
 # Keep this version of Soy synchronized with the version used in Gitiles.
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2017-02-01",
-    sha1 = "8638940b207779fe3b75e55b6e65abbefb6af678",
+    artifact = "com.google.template:soy:2017-04-23",
+    sha1 = "52f32a5a3801ab97e0909373ef7f73a3460d0802",
 )
 
 maven_jar(
@@ -627,8 +627,8 @@
 
 maven_jar(
     name = "dropwizard-core",
-    artifact = "io.dropwizard.metrics:metrics-core:4.0.3",
-    sha1 = "bb562ee73f740bb6b2bf7955f97be6b870d9e9f0",
+    artifact = "io.dropwizard.metrics:metrics-core:4.0.5",
+    sha1 = "b81ef162970cdb9f4512ee2da09715a856ff4c4c",
 )
 
 # When updating Bouncy Castle, also update it in bazlets.
@@ -656,15 +656,15 @@
 # https://issues.apache.org/jira/browse/SSHD-736
 maven_jar(
     name = "sshd",
-    artifact = "org.apache.sshd:sshd-core:1.4.0",
+    artifact = "org.apache.sshd:sshd-core:1.6.0",
     exclude = ["META-INF/services/java.nio.file.spi.FileSystemProvider"],
-    sha1 = "c8f3d7457fc9979d1b9ec319f0229b89793c8e56",
+    sha1 = "548e2da643e88cda9d313efb2564a74f9943e491",
 )
 
 maven_jar(
     name = "eddsa",
-    artifact = "net.i2p.crypto:eddsa:0.1.0",
-    sha1 = "8f5a3b165164e222da048d8136b21428ee0b9122",
+    artifact = "net.i2p.crypto:eddsa:0.2.0",
+    sha1 = "0856a92559c4daf744cb27c93cd8b7eb1f8c4780",
 )
 
 maven_jar(
@@ -679,6 +679,9 @@
     sha1 = "fd369423346b2f1525c413e33f8cf95b09c92cbd",
 )
 
+# Note that all of the following org.apache.httpcomponents have newer versions,
+# but 4.4.1 is the only version that is available for all of them.
+# TODO: Check what combination of new versions are compatible.
 HTTPCOMP_VERS = "4.4.1"
 
 maven_jar(
@@ -702,15 +705,15 @@
 # elasticsearch-rest-client explicitly depends on this version
 maven_jar(
     name = "httpasyncclient",
-    artifact = "org.apache.httpcomponents:httpasyncclient:4.1.2",
-    sha1 = "95aa3e6fb520191a0970a73cf09f62948ee614be",
+    artifact = "org.apache.httpcomponents:httpasyncclient:4.1.4",
+    sha1 = "f3a3240681faae3fa46b573a4c7e50cec9db0d86",
 )
 
 # elasticsearch-rest-client explicitly depends on this version
 maven_jar(
     name = "httpcore-nio",
-    artifact = "org.apache.httpcomponents:httpcore-nio:4.4.5",
-    sha1 = "f4be009e7505f6ceddf21e7960c759f413f15056",
+    artifact = "org.apache.httpcomponents:httpcore-nio:4.4.11",
+    sha1 = "7d0a97d01d39cff9aa3e6db81f21fddb2435f4e6",
 )
 
 # Test-only dependencies below.
@@ -733,23 +736,24 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-TRUTH_VERS = "0.32"
+TRUTH_VERS = "0.35"
 
 maven_jar(
     name = "truth",
     artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "e996fb4b41dad04365112786796c945f909cfdf7",
+    sha1 = "c08a7fde45e058323bcfa3f510d4fe1e2b028f37",
 )
 
 maven_jar(
     name = "truth-java8-extension",
     artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "2862787ce34cb6f385ada891e36ec7f9e7bd0902",
+    sha1 = "5457fdf91b1e954b070ad7f2db9bea5505da4bca",
 )
 
+# When bumping the easymock version number, make sure to also move powermock to a compatible version
 maven_jar(
     name = "easymock",
-    artifact = "org.easymock:easymock:3.1",  # When bumping the version
+    artifact = "org.easymock:easymock:3.1",
     sha1 = "3e127311a86fc2e8f550ef8ee4abe094bbcf7e7e",
 )
 
@@ -938,24 +942,28 @@
 # and httpasyncclient as necessary.
 maven_jar(
     name = "elasticsearch-rest-client",
-    artifact = "org.elasticsearch.client:elasticsearch-rest-client:6.4.3",
-    sha1 = "5c24325430971ba2fa4769eb446f026b7680d5e7",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.3.2",
+    sha1 = "38721e908cad8a30fa3f8e659c0571150a60cab3",
 )
 
-JACKSON_VERSION = "2.9.8"
-
 maven_jar(
     name = "jackson-core",
-    artifact = "com.fasterxml.jackson.core:jackson-core:" + JACKSON_VERSION,
+    artifact = "com.fasterxml.jackson.core:jackson-core:2.9.8",
     sha1 = "0f5a654e4675769c716e5b387830d19b501ca191",
 )
 
-TESTCONTAINERS_VERSION = "1.11.2"
+TESTCONTAINERS_VERSION = "1.12.1"
 
 maven_jar(
     name = "testcontainers",
     artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-    sha1 = "eae47ed24bb07270d4b60b5e2c3444c5bf3c8ea9",
+    sha1 = "1dc8666ead914c5515d087f75ffe92629414caf6",
+)
+
+maven_jar(
+    name = "testcontainers-elasticsearch",
+    artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
+    sha1 = "2491f792627a1f15d341bfcd6dd0ea7e3541d82f",
 )
 
 maven_jar(
@@ -996,8 +1004,8 @@
 bower_archive(
     name = "iron-autogrow-textarea",
     package = "polymerelements/iron-autogrow-textarea",
-    sha1 = "b9b6874c9a2b5be435557a827ff8bd6661672ee3",
-    version = "1.0.12",
+    sha1 = "68f0ece9b1e56ac26f8ce31d9938c504f6951bca",
+    version = "2.1.0",
 )
 
 bower_archive(
@@ -1017,8 +1025,8 @@
 bower_archive(
     name = "iron-dropdown",
     package = "polymerelements/iron-dropdown",
-    sha1 = "63e3d669a09edaa31c4f05afc76b53b919ef0595",
-    version = "1.4.0",
+    sha1 = "ac96fe31cdf203a63426fa75131b43c98c0597d3",
+    version = "1.5.5",
 )
 
 bower_archive(
@@ -1031,15 +1039,43 @@
 bower_archive(
     name = "iron-overlay-behavior",
     package = "polymerelements/iron-overlay-behavior",
-    sha1 = "83181085fda59446ce74fd0d5ca30c223f38ee4a",
-    version = "1.7.6",
+    sha1 = "74cda9d7bf98e7a5e5004bc7ebdb6d208d49e11e",
+    version = "2.0.0",
 )
 
 bower_archive(
     name = "iron-selector",
     package = "polymerelements/iron-selector",
-    sha1 = "c57235dfda7fbb987c20ad0e97aac70babf1a1bf",
-    version = "1.5.2",
+    sha1 = "e0ee46c28523bf17730318c3b481a8ed4331c3b2",
+    version = "2.0.0",
+)
+
+bower_archive(
+    name = "paper-button",
+    package = "polymerelements/paper-button",
+    sha1 = "41a8fec68d93dad223ad2076d68515334b2c8d7b",
+    version = "1.0.11",
+)
+
+bower_archive(
+    name = "paper-input",
+    package = "polymerelements/paper-input",
+    sha1 = "6c934805e80ab201e143406edc73ea0ef35abf80",
+    version = "1.1.18",
+)
+
+bower_archive(
+    name = "iron-icon",
+    package = "polymerelements/iron-icon",
+    sha1 = "7da49a0d33cd56017740e0dbcf41d2b71532023f",
+    version = "2.0.0",
+)
+
+bower_archive(
+    name = "iron-iconset-svg",
+    package = "polymerelements/iron-iconset-svg",
+    sha1 = "4d0c406239cad2ff2975c6dd95fa189de0fe6b50",
+    version = "2.1.0",
 )
 
 bower_archive(
@@ -1057,6 +1093,20 @@
 )
 
 bower_archive(
+    name = "paper-item",
+    package = "polymerelements/paper-item",
+    sha1 = "803273ceb9ffebec8ecc9373ea638af4cd34af58",
+    version = "1.1.4",
+)
+
+bower_archive(
+    name = "paper-listbox",
+    package = "polymerelements/paper-listbox",
+    sha1 = "ccc1a90ab0a96878c7bf7c9c4cfe47c85b09c8e3",
+    version = "2.0.0",
+)
+
+bower_archive(
     name = "polymer",
     package = "polymer/polymer",
     sha1 = "62ce80a5079c1b97f6c5c6ebf6b350e741b18b9c",
@@ -1064,6 +1114,13 @@
 )
 
 bower_archive(
+    name = "polymer-resin",
+    package = "polymer/polymer-resin",
+    sha1 = "94c29926c20ea3a9b636f26b3e0d689ead8137e5",
+    version = "2.0.1",
+)
+
+bower_archive(
     name = "promise-polyfill",
     package = "polymerlabs/promise-polyfill",
     sha1 = "a3b598c06cbd7f441402e666ff748326030905d6",
@@ -1089,8 +1146,8 @@
 bower_archive(
     name = "web-component-tester",
     package = "web-component-tester",
-    sha1 = "a4a9bc7815a22d143e8f8593e37b3c2028b8c20f",
-    version = "5.0.0",
+    sha1 = "4e778f8b7d784ba2a069d83d0cd146125c5c4fcb",
+    version = "5.0.1",
 )
 
 # Bower component transitive dependencies.
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index f62c767..99022aa 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -25,20 +25,23 @@
 
 """ Script to abandon stale changes from the review server.
 
-Fetches a list of open changes that have not been updated since a
-given age in months or years (default 6 months), and then abandons them.
+Fetches a list of open changes that have not been updated since a given age in
+days, months or years (default 6 months), and then abandons them.
 
-Assumes that the user's credentials are in the .netrc file.  Supports
-either basic or digest authentication.
+Requires the user's credentials for the Gerrit server to be declared in the
+.netrc file. Supports either basic or digest authentication.
 
 Example to abandon changes that have not been updated for 3 months:
 
   ./abandon_stale --gerrit-url http://review.example.com/ --age 3months
 
-Supports dry-run mode to only list the stale changes but not actually
+Supports dry-run mode to only list the stale changes, but not actually
 abandon them.
 
-Requires pygerrit2 (https://github.com/dpursehouse/pygerrit2).
+See the --help output for more information about options.
+
+Requires pygerrit2 (https://github.com/dpursehouse/pygerrit2) to be installed
+and available for import.
 
 """
 
@@ -59,7 +62,11 @@
                       help='gerrit server URL')
     parser.add_option('-b', '--basic-auth', dest='basic_auth',
                       action='store_true',
-                      help='use HTTP basic authentication instead of digest')
+                      help='(deprecated) use HTTP basic authentication instead'
+                      ' of digest')
+    parser.add_option('-d', '--digest-auth', dest='digest_auth',
+                      action='store_true',
+                      help='use HTTP digest authentication instead of basic')
     parser.add_option('-n', '--dry-run', dest='dry_run',
                       action='store_true',
                       help='enable dry-run mode: show stale changes but do '
@@ -67,32 +74,32 @@
     parser.add_option('-a', '--age', dest='age',
                       metavar='AGE',
                       default="6months",
-                      help='age of change since last update '
-                           '(default: %default)')
+                      help='age of change since last update in days, months'
+                           ' or years (default: %default)')
     parser.add_option('-m', '--message', dest='message',
                       metavar='STRING', default=None,
-                      help='Custom message to append to abandon message')
+                      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')
+                      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')
+                      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')
+                      help='abandon changes only on the given project')
     parser.add_option('--exclude-project', dest='exclude_projects',
                       metavar='PROJECT_NAME',
                       default=[],
                       action='append',
-                      help='Do not abandon changes on given project')
+                      help='do not abandon changes on given project')
     parser.add_option('--owner', dest='owner',
                       metavar='USERNAME',
                       default=None,
                       action='store',
-                      help='Only abandon changes owned by the given user')
+                      help='only abandon changes owned by the given user')
     parser.add_option('-v', '--verbose', dest='verbose',
                       action='store_true',
                       help='enable verbose (debug) logging')
@@ -115,10 +122,10 @@
     message = "Abandoning after %s %s or more of inactivity." % \
         (match.group(1), match.group(2))
 
-    if options.basic_auth:
-        auth_type = HTTPBasicAuthFromNetrc
-    else:
+    if options.digest_auth:
         auth_type = HTTPDigestAuthFromNetrc
+    else:
+        auth_type = HTTPBasicAuthFromNetrc
 
     try:
         auth = auth_type(url=options.gerrit_url)
@@ -145,7 +152,7 @@
             query_terms += ["owner:%s" % options.owner]
         query = "%20".join(query_terms)
         while True:
-            q = query + "&n=%d&S=%d" % (step, offset)
+            q = query + "&o=DETAILED_ACCOUNTS&n=%d&S=%d" % (step, offset)
             logging.debug("Query: %s", q)
             url = "/changes/?q=" + q
             result = gerrit.get(url)
@@ -184,7 +191,7 @@
 
         try:
             gerrit.post("/changes/" + change_id + "/abandon",
-                        data='{"message" : "%s"}' % abandon_message)
+                        json={"message" : "%s" % abandon_message})
             abandoned += 1
         except Exception as e:
             errors += 1
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index b77c41a..0e3dffe 100644
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -46,7 +46,7 @@
 PLUGINS_URL = BASE_URL + "plugins/"
 PROJECTS_URL = BASE_URL + "projects/"
 
-ADMIN_DIGEST = requests.auth.HTTPDigestAuth("admin", "secret")
+ADMIN_BASIC_AUTH = requests.auth.HTTPBasicAuth("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.
@@ -151,8 +151,8 @@
   return json_string
 
 
-def digest_auth(user):
-  return requests.auth.HTTPDigestAuth(user["username"], user["http_password"])
+def basic_auth(user):
+  return requests.auth.HTTPBasicAuth(user["username"], user["http_password"])
 
 
 def fetch_admin_group():
@@ -160,7 +160,7 @@
   # Get admin group
   r = json.loads(clean(requests.get(GROUPS_URL + "?suggest=ad&p=All-Projects",
                                     headers=HEADERS,
-                                    auth=ADMIN_DIGEST).text))
+                                    auth=ADMIN_BASIC_AUTH).text))
   admin_group_name = r.keys()[0]
   GROUP_ADMIN = r[admin_group_name]
   GROUP_ADMIN["name"] = admin_group_name
@@ -225,7 +225,7 @@
     requests.put(GROUPS_URL + g["name"],
                  json.dumps(g),
                  headers=HEADERS,
-                 auth=ADMIN_DIGEST)
+                 auth=ADMIN_BASIC_AUTH)
   return [g["name"] for g in groups]
 
 
@@ -247,7 +247,7 @@
     requests.put(PROJECTS_URL + p["name"],
                  json.dumps(p),
                  headers=HEADERS,
-                 auth=ADMIN_DIGEST)
+                 auth=ADMIN_BASIC_AUTH)
   return [p["name"] for p in projects]
 
 
@@ -256,7 +256,7 @@
     requests.put(ACCOUNTS_URL + user["username"],
                  json.dumps(user),
                  headers=HEADERS,
-                 auth=ADMIN_DIGEST)
+                 auth=ADMIN_BASIC_AUTH)
 
 
 def create_change(user, project_name):
@@ -270,7 +270,7 @@
   requests.post(CHANGES_URL,
                 json.dumps(change),
                 headers=HEADERS,
-                auth=digest_auth(user))
+                auth=basic_auth(user))
 
 
 def clean_up():
diff --git a/gerrit-acceptance-framework/BUILD b/gerrit-acceptance-framework/BUILD
index 795dedd..1d045e6 100644
--- a/gerrit-acceptance-framework/BUILD
+++ b/gerrit-acceptance-framework/BUILD
@@ -1,17 +1,26 @@
 load("@rules_java//java:defs.bzl", "java_binary")
 load("//tools/bzl:java.bzl", "java_library2")
 load("//tools/bzl:javadoc.bzl", "java_doc")
+load("//tools/bzl:junit.bzl", "junit_tests")
 
-SRCS = glob(["src/test/java/com/google/gerrit/acceptance/*.java"])
+TEST_SRCS = ["src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java"]
+
+SRCS = glob(
+    ["src/test/java/com/google/gerrit/acceptance/*.java"],
+    exclude = TEST_SRCS,
+)
 
 PROVIDED = [
     "//gerrit-common:annotations",
     "//gerrit-common:server",
     "//gerrit-extension-api:api",
     "//gerrit-httpd:httpd",
+    "//gerrit-index:index",
     "//gerrit-lucene:lucene",
     "//gerrit-pgm:init",
     "//gerrit-reviewdb:server",
+    "//gerrit-server:metrics",
+    "//gerrit-server:receive",
     "//gerrit-server:server",
     "//lib:gson",
     "//lib:jsch",
@@ -33,15 +42,15 @@
     testonly = 1,
     srcs = SRCS,
     exported_deps = [
-        "//gerrit-antlr:query_exception",
         "//gerrit-gpg:gpg",
+        "//gerrit-index:query_exception",
         "//gerrit-launcher:launcher",
         "//gerrit-openid:openid",
         "//gerrit-pgm:daemon",
         "//gerrit-pgm:http-jetty",
         "//gerrit-pgm:util-nodep",
+        "//gerrit-server:prolog-common",
         "//gerrit-server:testutil",
-        "//gerrit-server/src/main/prolog:common",
         "//lib:jimfs",
         "//lib:truth",
         "//lib:truth-java8-extension",
@@ -75,3 +84,14 @@
     title = "Gerrit Acceptance Test Framework Documentation",
     visibility = ["//visibility:public"],
 )
+
+junit_tests(
+    name = "acceptance_framework_tests",
+    srcs = TEST_SRCS,
+    deps = [
+        ":lib",
+        "//lib:guava",
+        "//lib:truth",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
index b1ae264..9e5d87c 100644
--- a/gerrit-acceptance-framework/pom.xml
+++ b/gerrit-acceptance-framework/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.14.20</version>
+  <version>2.15.17-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
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 8868987..477ff1e 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
@@ -21,6 +21,9 @@
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
 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 java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
@@ -29,7 +32,6 @@
 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.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
@@ -37,6 +39,9 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -64,6 +69,7 @@
 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.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ChangeFinder;
@@ -72,6 +78,7 @@
 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.Accounts;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.change.Abandon;
 import com.google.gerrit.server.change.ChangeResource;
@@ -85,29 +92,36 @@
 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.Groups;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.send.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.notedb.MutableNotesMigration;
 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.server.update.BatchUpdate;
 import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.FakeAuditService;
 import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
+import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.SshMode;
 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.Module;
 import com.google.inject.Provider;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -159,103 +173,24 @@
 @RunWith(ConfigSuite.class)
 public abstract class AbstractDaemonTest {
   private static GerritServer commonServer;
+  private static Description firstTest;
 
   @ConfigSuite.Parameter public Config baseConfig;
-
   @ConfigSuite.Name private String configName;
 
-  @Inject protected AllProjectsName allProjects;
-
-  @Inject protected AccountCreator accounts;
-
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-
-  @Inject protected GerritApi gApi;
-
-  @Inject protected AcceptanceTestRequestScope atrScope;
-
-  @Inject protected AccountCache accountCache;
-
-  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
-
-  @Inject protected PushOneCommit.Factory pushFactory;
-
-  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-
-  @Inject protected ProjectCache projectCache;
-
-  @Inject protected GroupCache groupCache;
-
-  @Inject protected GitRepositoryManager repoManager;
-
-  @Inject protected ChangeIndexer indexer;
-
-  @Inject protected Provider<InternalChangeQuery> queryProvider;
-
-  @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
-
-  @Inject @GerritServerConfig protected Config cfg;
-
-  @Inject protected PluginConfigFactory pluginConfig;
-
-  @Inject private InProcessProtocol inProcessProtocol;
-
-  @Inject private Provider<AnonymousUser> anonymousUser;
-
-  @Inject @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 protected SystemGroupBackend systemGroupBackend;
-
-  @Inject private EventRecorder.Factory eventRecorderFactory;
-
-  @Inject private ChangeIndexCollection changeIndexes;
-
-  protected TestRepository<InMemoryRepository> testRepo;
-  protected GerritServer server;
-  protected TestAccount admin;
-  protected TestAccount user;
-  protected RestSession adminRestSession;
-  protected RestSession userRestSession;
-  protected SshSession adminSshSession;
-  protected SshSession userSshSession;
-  protected ReviewDb db;
-  protected Project.NameKey project;
-  protected EventRecorder eventRecorder;
-
-  @Inject protected TestNotesMigration notesMigration;
-
-  @Inject protected ChangeNotes.Factory notesFactory;
-
-  @Inject protected Abandon changeAbandoner;
-
   @Rule public ExpectedException exception = ExpectedException.none();
 
-  private String resourcePrefix;
-  private List<Repository> toClose;
-  private boolean useSsh;
-
   @Rule
   public TestRule testRunner =
       new TestRule() {
         @Override
-        public Statement apply(final Statement base, final Description description) {
+        public Statement apply(Statement base, Description description) {
           return new Statement() {
             @Override
             public void evaluate() throws Throwable {
+              if (firstTest == null) {
+                firstTest = description;
+              }
               beforeTest(description);
               try {
                 base.evaluate();
@@ -267,6 +202,63 @@
         }
       };
 
+  @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
+  @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
+  @Inject @GerritServerConfig protected Config cfg;
+  @Inject protected AcceptanceTestRequestScope atrScope;
+  @Inject protected AccountCache accountCache;
+  @Inject protected AccountCreator accountCreator;
+  @Inject protected Accounts accounts;
+  @Inject protected AllProjectsName allProjects;
+  @Inject protected BatchUpdate.Factory batchUpdateFactory;
+  @Inject protected ChangeData.Factory changeDataFactory;
+  @Inject protected ChangeFinder changeFinder;
+  @Inject protected ChangeIndexer indexer;
+  @Inject protected ChangeNoteUtil changeNoteUtil;
+  @Inject protected ChangeResource.Factory changeResourceFactory;
+  @Inject protected FakeEmailSender sender;
+  @Inject protected FakeAuditService auditService;
+  @Inject protected GerritApi gApi;
+  @Inject protected GitRepositoryManager repoManager;
+  @Inject protected GroupCache groupCache;
+  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected PatchSetUtil psUtil;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected Provider<InternalChangeQuery> queryProvider;
+  @Inject protected PushOneCommit.Factory pushFactory;
+  @Inject protected PluginConfigFactory pluginConfig;
+  @Inject protected Revisions revisions;
+  @Inject protected SystemGroupBackend systemGroupBackend;
+  @Inject protected MutableNotesMigration notesMigration;
+  @Inject protected ChangeNotes.Factory notesFactory;
+  @Inject protected Abandon changeAbandoner;
+
+  protected EventRecorder eventRecorder;
+  protected GerritServer server;
+  protected Project.NameKey project;
+  protected RestSession adminRestSession;
+  protected RestSession userRestSession;
+  protected ReviewDb db;
+  protected SshSession adminSshSession;
+  protected SshSession userSshSession;
+  protected TestAccount admin;
+  protected TestAccount user;
+  protected TestRepository<InMemoryRepository> testRepo;
+  protected String resourcePrefix;
+  protected Description description;
+  protected boolean testRequiresSsh;
+  protected Module testSysModule;
+
+  @Inject private ChangeIndexCollection changeIndexes;
+  @Inject private EventRecorder.Factory eventRecorderFactory;
+  @Inject private InProcessProtocol inProcessProtocol;
+  @Inject private Provider<AnonymousUser> anonymousUser;
+  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
+  @Inject private Groups groups;
+
+  private List<Repository> toClose;
+
   @Before
   public void clearSender() {
     sender.clear();
@@ -279,7 +271,7 @@
 
   @Before
   public void assumeSshIfRequired() {
-    if (useSsh) {
+    if (testRequiresSsh) {
       // If the test uses ssh, we use assume() to make sure ssh is enabled on
       // the test suite. JUnit will skip tests annotated with @UseSsh if we
       // disable them using the command line flag.
@@ -297,6 +289,11 @@
     if (commonServer != null) {
       try {
         commonServer.close();
+      } catch (Throwable t) {
+        throw new AssertionError(
+            "Error stopping common server in "
+                + (firstTest != null ? firstTest.getTestClass().getName() : "unknown test class"),
+            t);
       } finally {
         commonServer = null;
       }
@@ -310,16 +307,6 @@
     return cfg;
   }
 
-  protected static Config allowDraftsDisabledConfig() {
-    Config cfg = new Config();
-    cfg.setBoolean("change", null, "allowDrafts", false);
-    return cfg;
-  }
-
-  protected boolean isAllowDrafts() {
-    return cfg.getBoolean("change", "allowDrafts", true);
-  }
-
   protected boolean isSubmitWholeTopicEnabled() {
     return cfg.getBoolean("change", null, "submitWholeTopic", false);
   }
@@ -329,6 +316,7 @@
   }
 
   protected void beforeTest(Description description) throws Exception {
+    this.description = description;
     GerritServer.Description classDesc =
         GerritServer.Description.forTestClass(description, configName);
     GerritServer.Description methodDesc =
@@ -337,19 +325,35 @@
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
       if (commonServer == null) {
-        commonServer = GerritServer.initAndStart(classDesc, baseConfig);
+        commonServer = GerritServer.initAndStart(classDesc, baseConfig, testSysModule);
       }
       server = commonServer;
     } else {
-      server = GerritServer.initAndStart(methodDesc, baseConfig);
+      server = GerritServer.initAndStart(methodDesc, baseConfig, testSysModule);
     }
 
     server.getTestInjector().injectMembers(this);
-    notesMigration.setFromEnv();
     Transport.register(inProcessProtocol);
     toClose = Collections.synchronizedList(new ArrayList<Repository>());
-    admin = accounts.admin();
-    user = accounts.user();
+
+    db = reviewDbProvider.open();
+
+    // All groups which were added during the server start (e.g. in SchemaCreator) aren't contained
+    // in the instance of the group index which is available here and in tests. There are two
+    // reasons:
+    // 1) No group index is available in SchemaCreator when using an in-memory database. (This could
+    // be fixed by using the IndexManagerOnInit in InMemoryDatabase similar as BaseInit uses it.)
+    // 2) During the on-init part of the server start, we use another instance of the index than
+    // later on. As test indexes are non-permanent, closing an instance and opening another one
+    // removes all indexed data.
+    // As a workaround, we simply reindex all available groups here.
+    Iterable<AccountGroup> allGroups = groups.getAll(db)::iterator;
+    for (AccountGroup group : allGroups) {
+      groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
+    }
+
+    admin = accountCreator.admin();
+    user = accountCreator.user();
 
     // Evict cached user state in case tests modify it.
     accountCache.evict(admin.getId());
@@ -358,24 +362,20 @@
     adminRestSession = new RestSession(server, admin);
     userRestSession = new RestSession(server, user);
 
-    db = reviewDbProvider.open();
-
-    if (classDesc.useSsh() || methodDesc.useSsh()) {
-      useSsh = true;
-      if (SshMode.useSsh() && (adminSshSession == null || userSshSession == null)) {
-        // Create Ssh sessions
-        initSsh(admin);
-        Context ctx = newRequestContext(user);
-        atrScope.set(ctx);
-        userSshSession = ctx.getSession();
-        userSshSession.open();
-        ctx = newRequestContext(admin);
-        atrScope.set(ctx);
-        adminSshSession = ctx.getSession();
-        adminSshSession.open();
-      }
-    } else {
-      useSsh = false;
+    testRequiresSsh = classDesc.useSshAnnotation() || methodDesc.useSshAnnotation();
+    if (testRequiresSsh
+        && SshMode.useSsh()
+        && (adminSshSession == null || userSshSession == null)) {
+      // Create Ssh sessions
+      initSsh(admin);
+      Context ctx = newRequestContext(user);
+      atrScope.set(ctx);
+      userSshSession = ctx.getSession();
+      userSshSession.open();
+      ctx = newRequestContext(admin);
+      atrScope.set(ctx);
+      adminSshSession = ctx.getSession();
+      adminSshSession.open();
     }
 
     resourcePrefix =
@@ -391,7 +391,7 @@
 
   private TestAccount getCloneAsAccount(Description description) {
     TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
-    return accounts.get(ann != null ? ann.cloneAs() : "admin");
+    return accountCreator.get(ann != null ? ann.cloneAs() : "admin");
   }
 
   private ProjectInput projectInput(Description description) {
@@ -406,6 +406,8 @@
       in.useContentMerge = ann.useContributorAgreements();
       in.useSignedOffBy = ann.useSignedOffBy();
       in.useContentMerge = ann.useContentMerge();
+      in.enableSignedPush = ann.enableSignedPush();
+      in.requireSignedPush = ann.requireSignedPush();
     } else {
       // Defaults should match TestProjectConfig, omitting nullable values.
       in.createEmptyCommit = true;
@@ -491,12 +493,22 @@
 
   protected TestRepository<InMemoryRepository> cloneProject(
       Project.NameKey p, TestAccount testAccount) throws Exception {
+    return GitUtil.cloneProject(p, registerRepoConnection(p, testAccount));
+  }
+
+  /**
+   * Register a repository connection over the test protocol.
+   *
+   * @return a URI string that can be used to connect to this repository for both fetch and push.
+   */
+  protected String registerRepoConnection(Project.NameKey p, TestAccount testAccount)
+      throws Exception {
     InProcessProtocol.Context ctx =
         new InProcessProtocol.Context(
             reviewDbProvider, identifiedUserFactory, testAccount.getId(), p);
     Repository repo = repoManager.openRepository(p);
     toClose.add(repo);
-    return GitUtil.cloneProject(p, inProcessProtocol.register(ctx, repo).toString());
+    return inProcessProtocol.register(ctx, repo).toString();
   }
 
   protected void afterTest() throws Exception {
@@ -515,6 +527,7 @@
       server.close();
       server = null;
     }
+    NoteDbMode.resetFromEnv(notesMigration);
   }
 
   protected TestRepository<?>.CommitBuilder commitBuilder() throws Exception {
@@ -589,8 +602,37 @@
     return result;
   }
 
-  protected PushOneCommit.Result createDraftChange() throws Exception {
-    return pushTo("refs/drafts/master");
+  protected PushOneCommit.Result createCommitAndPush(
+      TestRepository<InMemoryRepository> repo,
+      String ref,
+      String commitMsg,
+      String fileName,
+      String content)
+      throws Exception {
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), repo, commitMsg, fileName, content).to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
+  protected PushOneCommit.Result createChangeWithTopic() throws Exception {
+    return createChangeWithTopic(testRepo, "topic", "message", "a.txt", "content\n");
+  }
+
+  protected PushOneCommit.Result createChangeWithTopic(
+      TestRepository<InMemoryRepository> repo,
+      String topic,
+      String commitMsg,
+      String fileName,
+      String content)
+      throws Exception {
+    assertThat(topic).isNotEmpty();
+    return createCommitAndPush(
+        repo, "refs/for/master/" + name(topic), commitMsg, fileName, content);
+  }
+
+  protected PushOneCommit.Result createWorkInProgressChange() throws Exception {
+    return pushTo("refs/for/master%wip");
   }
 
   protected PushOneCommit.Result createChange(String subject, String fileName, String content)
@@ -682,10 +724,6 @@
     revision(r).submit();
   }
 
-  protected PushOneCommit.Result amendChangeAsDraft(String changeId) throws Exception {
-    return amendChange(changeId, "refs/drafts/master");
-  }
-
   protected ChangeInfo info(String id) throws RestApiException {
     return gApi.changes().id(id).info();
   }
@@ -699,9 +737,7 @@
   }
 
   protected ChangeInfo get(String id, ListChangesOption... options) throws RestApiException {
-    return gApi.changes()
-        .id(id)
-        .get(Sets.newEnumSet(Arrays.asList(options), ListChangesOption.class));
+    return gApi.changes().id(id).get(options);
   }
 
   protected List<ChangeInfo> query(String q) throws RestApiException {
@@ -734,12 +770,12 @@
   }
 
   protected Context disableDb() {
-    notesMigration.setFailOnLoad(true);
+    notesMigration.setFailOnLoadForTest(true);
     return atrScope.disableDb();
   }
 
   protected void enableDb(Context preDisableContext) {
-    notesMigration.setFailOnLoad(false);
+    notesMigration.setFailOnLoadForTest(false);
     atrScope.set(preDisableContext);
   }
 
@@ -767,10 +803,15 @@
     return gApi.changes().id(r.getChangeId()).current();
   }
 
-  protected void allow(String permission, AccountGroup.UUID id, String ref) throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+  protected void allow(String ref, String permission, AccountGroup.UUID id) throws Exception {
+    allow(project, ref, permission, id);
+  }
+
+  protected void allow(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
     Util.allow(cfg, permission, id, ref);
-    saveProjectConfig(project, cfg);
+    saveProjectConfig(p, cfg);
   }
 
   protected void allowGlobalCapabilities(AccountGroup.UUID id, String... capabilityNames)
@@ -828,24 +869,24 @@
     }
   }
 
-  protected void deny(String permission, AccountGroup.UUID id, String ref) throws Exception {
-    deny(project, permission, id, ref);
+  protected void deny(String ref, String permission, AccountGroup.UUID id) throws Exception {
+    deny(project, ref, permission, id);
   }
 
-  protected void deny(Project.NameKey p, String permission, AccountGroup.UUID id, String ref)
+  protected void deny(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
       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)
+  protected PermissionRule block(String ref, String permission, AccountGroup.UUID id)
       throws Exception {
-    return block(permission, id, ref, project);
+    return block(project, ref, permission, id);
   }
 
   protected PermissionRule block(
-      String permission, AccountGroup.UUID id, String ref, Project.NameKey project)
+      Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
       throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     PermissionRule rule = Util.block(cfg, permission, id, ref);
@@ -873,21 +914,22 @@
     saveProjectConfig(project, cfg);
   }
 
-  protected void grant(String permission, Project.NameKey project, String ref)
+  protected void grant(Project.NameKey project, String ref, String permission)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    grant(permission, project, ref, false);
+    grant(project, ref, permission, false);
   }
 
-  protected void grant(String permission, Project.NameKey project, String ref, boolean force)
+  protected void grant(Project.NameKey project, String ref, String permission, boolean force)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    AccountGroup adminGroup = groupCache.get(new AccountGroup.NameKey("Administrators"));
-    grant(permission, project, ref, force, adminGroup.getGroupUUID());
+    InternalGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
+    grant(project, ref, permission, force, adminGroup.getGroupUUID());
   }
 
   protected void grant(
-      String permission,
       Project.NameKey project,
       String ref,
+      String permission,
       boolean force,
       AccountGroup.UUID groupUUID)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
@@ -931,7 +973,7 @@
     }
   }
 
-  protected void removePermission(String permission, Project.NameKey project, String ref)
+  protected void removePermission(Project.NameKey project, String ref, String permission)
       throws IOException, ConfigInvalidException {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       md.setMessage(String.format("Remove %s on %s", permission, ref));
@@ -945,7 +987,7 @@
   }
 
   protected void blockRead(String ref) throws Exception {
-    block(Permission.READ, REGISTERED_USERS, ref);
+    block(ref, Permission.READ, REGISTERED_USERS);
   }
 
   protected void blockForgeCommitter(Project.NameKey project, String ref) throws Exception {
@@ -1015,9 +1057,9 @@
   }
 
   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));
+    List<ChangeNotes> notes = changeFinder.find(changeId);
+    assertThat(notes).hasSize(1);
+    return changeResourceFactory.create(notes.get(0), atrScope.get().getUser());
   }
 
   protected String createGroup(String name) throws Exception {
@@ -1041,6 +1083,12 @@
     return name;
   }
 
+  protected String createAccount(String name, String group) throws Exception {
+    name = name(name);
+    accountCreator.create(name, group);
+    return name;
+  }
+
   protected RevCommit getHead(Repository repo, String name) throws Exception {
     try (RevWalk rw = new RevWalk(repo)) {
       Ref r = repo.exactRef(name);
@@ -1067,10 +1115,10 @@
   }
 
   protected void grantTagPermissions() throws Exception {
-    grant(Permission.CREATE, project, R_TAGS + "*");
-    grant(Permission.DELETE, project, R_TAGS + "");
-    grant(Permission.CREATE_TAG, project, R_TAGS + "*");
-    grant(Permission.CREATE_SIGNED_TAG, project, R_TAGS + "*");
+    grant(project, R_TAGS + "*", Permission.CREATE);
+    grant(project, R_TAGS + "", Permission.DELETE);
+    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    grant(project, R_TAGS + "*", Permission.CREATE_SIGNED_TAG);
   }
 
   protected void assertMailReplyTo(Message message, String email) throws Exception {
@@ -1086,8 +1134,9 @@
       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);
+      InternalGroup caGroup =
+          groupCache.get(new AccountGroup.UUID(groupApi.detail().id)).orElse(null);
+      GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
       PermissionRule rule = new PermissionRule(groupRef);
       rule.setAction(PermissionRule.Action.ALLOW);
       ca = new ContributorAgreement("cla-test");
@@ -1122,6 +1171,8 @@
   /**
    * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
    * resulting tree id.
+   *
+   * <p>Omits NoteDb meta refs.
    */
   protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
     assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
@@ -1155,11 +1206,12 @@
                   NullProgressMonitor.INSTANCE,
                   Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
           for (Ref r : fr.getAdvertisedRefs()) {
-            String branchName = r.getName();
-            Branch.NameKey n = new Branch.NameKey(proj, branchName);
-
+            String refName = r.getName();
+            if (RefNames.isNoteDbMetaRef(refName)) {
+              continue;
+            }
             RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(n, c.getTree().copy());
+            ret.put(new Branch.NameKey(proj, refName), c.getTree().copy());
           }
         }
       }
@@ -1235,8 +1287,8 @@
   protected TestRepository<?> createProjectWithPush(
       String name, @Nullable Project.NameKey parent, SubmitType submitType) throws Exception {
     Project.NameKey project = createProject(name, parent, true, submitType);
-    grant(Permission.PUSH, project, "refs/heads/*");
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
+    grant(project, "refs/heads/*", Permission.PUSH);
+    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
     return cloneProject(project);
   }
 
@@ -1252,21 +1304,29 @@
   }
 
   protected void assertNotifyTo(TestAccount expected) {
+    assertNotifyTo(expected.emailAddress);
+  }
+
+  protected void assertNotifyTo(Address expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.rcpt()).containsExactly(expected);
     assertThat(((EmailHeader.AddressList) m.headers().get("To")).getAddressList())
-        .containsExactly(expected.emailAddress);
+        .containsExactly(expected);
     assertThat(m.headers().get("CC").isEmpty()).isTrue();
   }
 
   protected void assertNotifyCc(TestAccount expected) {
+    assertNotifyCc(expected.emailAddress);
+  }
+
+  protected void assertNotifyCc(Address expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.rcpt()).containsExactly(expected);
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(((EmailHeader.AddressList) m.headers().get("CC")).getAddressList())
-        .containsExactly(expected.emailAddress);
+        .containsExactly(expected);
   }
 
   protected void assertNotifyBcc(TestAccount expected) {
@@ -1277,16 +1337,79 @@
     assertThat(m.headers().get("CC").isEmpty()).isTrue();
   }
 
-  protected void watch(String project, String filter) throws RestApiException {
-    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+  protected interface ProjectWatchInfoConfiguration {
+    void configure(ProjectWatchInfo pwi);
+  }
+
+  protected void watch(String project, ProjectWatchInfoConfiguration config)
+      throws RestApiException {
     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);
+    config.configure(pwi);
+    gApi.accounts().self().setWatchedProjects(ImmutableList.of(pwi));
+  }
+
+  protected void watch(PushOneCommit.Result r, ProjectWatchInfoConfiguration config)
+      throws OrmException, RestApiException {
+    watch(r.getChange().project().get(), config);
+  }
+
+  protected void watch(String project, String filter) throws RestApiException {
+    watch(
+        project,
+        pwi -> {
+          pwi.filter = filter;
+          pwi.notifyAbandonedChanges = true;
+          pwi.notifyNewChanges = true;
+          pwi.notifyAllComments = true;
+        });
+  }
+
+  protected void watch(String project) throws RestApiException {
+    watch(project, (String) null);
+  }
+
+  protected void assertContent(PushOneCommit.Result pushResult, String path, String expectedContent)
+      throws Exception {
+    BinaryResult bin =
+        gApi.changes()
+            .id(pushResult.getChangeId())
+            .revision(pushResult.getCommit().name())
+            .file(path)
+            .content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String res = new String(os.toByteArray(), UTF_8);
+    assertThat(res).isEqualTo(expectedContent);
+  }
+
+  protected RevCommit createNewCommitWithoutChangeId(String branch, String file, String content)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk walk = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(branch);
+      RevCommit tip = null;
+      if (ref != null) {
+        tip = walk.parseCommit(ref.getObjectId());
+      }
+      TestRepository<?> testSrcRepo = new TestRepository<>(repo);
+      TestRepository<?>.BranchBuilder builder = testSrcRepo.branch(branch);
+      RevCommit revCommit =
+          tip == null
+              ? builder.commit().message("commit 1").add(file, content).create()
+              : builder.commit().parent(tip).message("commit 1").add(file, content).create();
+      assertThat(GitUtil.getChangeId(testSrcRepo, revCommit).isPresent()).isFalse();
+      return revCommit;
+    }
+  }
+
+  protected RevCommit parseCurrentRevision(RevWalk rw, PushOneCommit.Result r) throws Exception {
+    return parseCurrentRevision(rw, r.getChangeId());
+  }
+
+  protected RevCommit parseCurrentRevision(RevWalk rw, String changeId) throws Exception {
+    return rw.parseCommit(
+        ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
   }
 
   protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
@@ -1294,4 +1417,35 @@
     config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
     saveProjectConfig(project, config);
   }
+
+  protected void configLabel(String label, LabelFunction func) throws Exception {
+    configLabel(label, func, ImmutableList.of());
+  }
+
+  protected void configLabel(String label, LabelFunction func, List<String> refPatterns)
+      throws Exception {
+    configLabel(
+        project,
+        label,
+        func,
+        refPatterns,
+        value(1, "Passes"),
+        value(0, "No score"),
+        value(-1, "Failed"));
+  }
+
+  private void configLabel(
+      Project.NameKey project,
+      String label,
+      LabelFunction func,
+      List<String> refPatterns,
+      LabelValue... value)
+      throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType labelType = category(label, value);
+    labelType.setFunction(func);
+    labelType.setRefPatterns(refPatterns);
+    cfg.getLabelSections().put(labelType.getName(), labelType);
+    saveProjectConfig(project, cfg);
+  }
 }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
new file mode 100644
index 0000000..8aa7766
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -0,0 +1,526 @@
+// 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.acceptance;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.api.changes.RecipientType.BCC;
+import static com.google.gerrit.extensions.api.changes.RecipientType.CC;
+import static com.google.gerrit.extensions.api.changes.RecipientType.TO;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.EmailHeader;
+import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
+import com.google.gerrit.testutil.FakeEmailSender;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import java.io.IOException;
+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.function.Function;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.After;
+import org.junit.Before;
+
+public abstract class AbstractNotificationTest extends AbstractDaemonTest {
+  @Before
+  public void enableReviewerByEmail() throws Exception {
+    setApiUser(admin);
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+  }
+
+  private static final SubjectFactory<FakeEmailSenderSubject, FakeEmailSender>
+      FAKE_EMAIL_SENDER_SUBJECT_FACTORY =
+          new SubjectFactory<FakeEmailSenderSubject, FakeEmailSender>() {
+            @Override
+            public FakeEmailSenderSubject getSubject(
+                FailureStrategy failureStrategy, FakeEmailSender target) {
+              return new FakeEmailSenderSubject(failureStrategy, target);
+            }
+          };
+
+  protected static FakeEmailSenderSubject assertThat(FakeEmailSender sender) {
+    return assertAbout(FAKE_EMAIL_SENDER_SUBJECT_FACTORY).that(sender);
+  }
+
+  protected void setEmailStrategy(TestAccount account, EmailStrategy strategy) throws Exception {
+    setEmailStrategy(account, strategy, true);
+  }
+
+  protected void setEmailStrategy(TestAccount account, EmailStrategy strategy, boolean record)
+      throws Exception {
+    if (record) {
+      accountsModifyingEmailStrategy.add(account);
+    }
+    setApiUser(account);
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = strategy;
+    gApi.accounts().self().setPreferences(prefs);
+  }
+
+  protected static class FakeEmailSenderSubject
+      extends Subject<FakeEmailSenderSubject, FakeEmailSender> {
+    private Message message;
+    private StagedUsers users;
+    private Map<RecipientType, List<String>> recipients = new HashMap<>();
+    private Set<String> accountedFor = new HashSet<>();
+
+    FakeEmailSenderSubject(FailureStrategy failureStrategy, FakeEmailSender target) {
+      super(failureStrategy, target);
+    }
+
+    public FakeEmailSenderSubject notSent() {
+      if (actual().peekMessage() != null) {
+        fail("a message wasn't sent");
+      }
+      return this;
+    }
+
+    public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
+      message = actual().nextMessage();
+      if (message == null) {
+        fail("a message was sent");
+      }
+      recipients = new HashMap<>();
+      recipients.put(TO, parseAddresses(message, "To"));
+      recipients.put(CC, parseAddresses(message, "CC"));
+      recipients.put(
+          BCC,
+          message.rcpt().stream()
+              .map(Address::getEmail)
+              .filter(e -> !recipients.get(TO).contains(e) && !recipients.get(CC).contains(e))
+              .collect(toList()));
+      this.users = users;
+      if (!message.headers().containsKey("X-Gerrit-MessageType")) {
+        fail("a message was sent with X-Gerrit-MessageType header");
+      }
+      EmailHeader header = message.headers().get("X-Gerrit-MessageType");
+      if (!header.equals(new EmailHeader.String(messageType))) {
+        fail("message of type " + messageType + " was sent; X-Gerrit-MessageType is " + header);
+      }
+
+      // Return a named subject that displays a human-readable table of
+      // recipients.
+      return named(recipientMapToString(recipients, e -> users.emailToName(e)));
+    }
+
+    private static String recipientMapToString(
+        Map<RecipientType, List<String>> recipients, Function<String, String> emailToName) {
+      StringBuilder buf = new StringBuilder();
+      buf.append('[');
+      for (RecipientType type : ImmutableList.of(TO, CC, BCC)) {
+        buf.append('\n');
+        buf.append(type);
+        buf.append(':');
+        String delim = " ";
+        for (String r : recipients.get(type)) {
+          buf.append(delim);
+          buf.append(emailToName.apply(r));
+          delim = ", ";
+        }
+      }
+      buf.append("\n]");
+      return buf.toString();
+    }
+
+    List<String> parseAddresses(Message msg, String headerName) {
+      EmailHeader header = msg.headers().get(headerName);
+      if (header == null) {
+        return ImmutableList.of();
+      }
+      Truth.assertThat(header).isInstanceOf(AddressList.class);
+      AddressList addrList = (AddressList) header;
+      return addrList.getAddressList().stream().map(Address::getEmail).collect(toList());
+    }
+
+    public FakeEmailSenderSubject to(String... emails) {
+      return rcpt(users.supportReviewersByEmail ? TO : null, emails);
+    }
+
+    public FakeEmailSenderSubject cc(String... emails) {
+      return rcpt(users.supportReviewersByEmail ? CC : null, emails);
+    }
+
+    public FakeEmailSenderSubject bcc(String... emails) {
+      return rcpt(users.supportReviewersByEmail ? BCC : null, emails);
+    }
+
+    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, String[] emails) {
+      for (String email : emails) {
+        rcpt(type, email);
+      }
+      return this;
+    }
+
+    private void rcpt(@Nullable RecipientType type, String email) {
+      rcpt(TO, email, TO.equals(type));
+      rcpt(CC, email, CC.equals(type));
+      rcpt(BCC, email, BCC.equals(type));
+    }
+
+    private void rcpt(@Nullable RecipientType type, String email, boolean expected) {
+      if (recipients.get(type).contains(email) != expected) {
+        fail(
+            expected ? "notifies" : "doesn't notify",
+            "]\n" + type + ": " + users.emailToName(email) + "\n]");
+      }
+      if (expected) {
+        accountedFor.add(email);
+      }
+    }
+
+    public FakeEmailSenderSubject noOneElse() {
+      for (Map.Entry<NotifyType, TestAccount> watchEntry : users.watchers.entrySet()) {
+        if (!accountedFor.contains(watchEntry.getValue().email)) {
+          notTo(watchEntry.getKey());
+        }
+      }
+
+      Map<RecipientType, List<String>> unaccountedFor = new HashMap<>();
+      boolean ok = true;
+      for (Map.Entry<RecipientType, List<String>> entry : recipients.entrySet()) {
+        unaccountedFor.put(entry.getKey(), new ArrayList<>());
+        for (String address : entry.getValue()) {
+          if (!accountedFor.contains(address)) {
+            unaccountedFor.get(entry.getKey()).add(address);
+            ok = false;
+          }
+        }
+      }
+      if (!ok) {
+        fail(
+            "was fully tested, missing assertions for: "
+                + recipientMapToString(unaccountedFor, e -> users.emailToName(e)));
+      }
+      return this;
+    }
+
+    public FakeEmailSenderSubject notTo(String... emails) {
+      return rcpt(null, emails);
+    }
+
+    public FakeEmailSenderSubject to(TestAccount... accounts) {
+      return rcpt(TO, accounts);
+    }
+
+    public FakeEmailSenderSubject cc(TestAccount... accounts) {
+      return rcpt(CC, accounts);
+    }
+
+    public FakeEmailSenderSubject bcc(TestAccount... accounts) {
+      return rcpt(BCC, accounts);
+    }
+
+    public FakeEmailSenderSubject notTo(TestAccount... accounts) {
+      return rcpt(null, accounts);
+    }
+
+    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, TestAccount[] accounts) {
+      for (TestAccount account : accounts) {
+        rcpt(type, account);
+      }
+      return this;
+    }
+
+    private void rcpt(@Nullable RecipientType type, TestAccount account) {
+      rcpt(type, account.email);
+    }
+
+    public FakeEmailSenderSubject to(NotifyType... watches) {
+      return rcpt(TO, watches);
+    }
+
+    public FakeEmailSenderSubject cc(NotifyType... watches) {
+      return rcpt(CC, watches);
+    }
+
+    public FakeEmailSenderSubject bcc(NotifyType... watches) {
+      return rcpt(BCC, watches);
+    }
+
+    public FakeEmailSenderSubject notTo(NotifyType... watches) {
+      return rcpt(null, watches);
+    }
+
+    private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, NotifyType[] watches) {
+      for (NotifyType watch : watches) {
+        rcpt(type, watch);
+      }
+      return this;
+    }
+
+    private void rcpt(@Nullable RecipientType type, NotifyType watch) {
+      if (!users.watchers.containsKey(watch)) {
+        fail("configured to watch", watch);
+      }
+      rcpt(type, users.watchers.get(watch));
+    }
+  }
+
+  private static final Map<String, StagedUsers> stagedUsers = new HashMap<>();
+
+  // TestAccount doesn't implement hashCode/equals, so this set is according
+  // to object identity. That's fine for our purposes.
+  private Set<TestAccount> accountsModifyingEmailStrategy = new HashSet<>();
+
+  @After
+  public void resetEmailStrategies() throws Exception {
+    for (TestAccount account : accountsModifyingEmailStrategy) {
+      setEmailStrategy(account, EmailStrategy.ENABLED, false);
+    }
+    accountsModifyingEmailStrategy.clear();
+  }
+
+  protected class StagedUsers {
+    public final TestAccount owner;
+    public final TestAccount author;
+    public final TestAccount uploader;
+    public final TestAccount reviewer;
+    public final TestAccount ccer;
+    public final TestAccount starrer;
+    public final TestAccount assignee;
+    public final TestAccount watchingProjectOwner;
+    public final String reviewerByEmail = "reviewerByEmail@example.com";
+    public final String ccerByEmail = "ccByEmail@example.com";
+    private final Map<NotifyType, TestAccount> watchers = new HashMap<>();
+    private final Map<String, TestAccount> accountsByEmail = new HashMap<>();
+    boolean supportReviewersByEmail;
+
+    private String usersCacheKey() {
+      return description.getClassName();
+    }
+
+    private TestAccount evictAndCopy(TestAccount account) throws IOException {
+      accountCache.evict(account.id);
+      return account;
+    }
+
+    public StagedUsers() throws Exception {
+      synchronized (stagedUsers) {
+        if (stagedUsers.containsKey(usersCacheKey())) {
+          StagedUsers existing = stagedUsers.get(usersCacheKey());
+          owner = evictAndCopy(existing.owner);
+          author = evictAndCopy(existing.author);
+          uploader = evictAndCopy(existing.uploader);
+          reviewer = evictAndCopy(existing.reviewer);
+          ccer = evictAndCopy(existing.ccer);
+          starrer = evictAndCopy(existing.starrer);
+          assignee = evictAndCopy(existing.assignee);
+          watchingProjectOwner = evictAndCopy(existing.watchingProjectOwner);
+          watchers.putAll(existing.watchers);
+          return;
+        }
+
+        owner = testAccount("owner");
+        reviewer = testAccount("reviewer");
+        author = testAccount("author");
+        uploader = testAccount("uploader");
+        ccer = testAccount("ccer");
+        starrer = testAccount("starrer");
+        assignee = testAccount("assignee");
+
+        watchingProjectOwner = testAccount("watchingProjectOwner", "Administrators");
+        setApiUser(watchingProjectOwner);
+        watch(allProjects.get(), pwi -> pwi.notifyNewChanges = true);
+
+        for (NotifyType watch : NotifyType.values()) {
+          if (watch == NotifyType.ALL) {
+            continue;
+          }
+          TestAccount watcher = testAccount(watch.toString());
+          setApiUser(watcher);
+          watch(
+              allProjects.get(),
+              pwi -> {
+                pwi.notifyAllComments = watch.equals(NotifyType.ALL_COMMENTS);
+                pwi.notifyAbandonedChanges = watch.equals(NotifyType.ABANDONED_CHANGES);
+                pwi.notifyNewChanges = watch.equals(NotifyType.NEW_CHANGES);
+                pwi.notifyNewPatchSets = watch.equals(NotifyType.NEW_PATCHSETS);
+                pwi.notifySubmittedChanges = watch.equals(NotifyType.SUBMITTED_CHANGES);
+              });
+          watchers.put(watch, watcher);
+        }
+
+        stagedUsers.put(usersCacheKey(), this);
+      }
+    }
+
+    private String email(String username) {
+      // Email validator rejects usernames longer than 64 bytes.
+      if (username.length() > 64) {
+        username = username.substring(username.length() - 64);
+        if (username.startsWith(".")) {
+          username = username.substring(1);
+        }
+      }
+      return username + "@example.com";
+    }
+
+    public TestAccount testAccount(String name) throws Exception {
+      String username = name(name);
+      TestAccount account = accountCreator.create(username, email(username), name);
+      accountsByEmail.put(account.email, account);
+      return account;
+    }
+
+    public TestAccount testAccount(String name, String groupName) throws Exception {
+      String username = name(name);
+      TestAccount account = accountCreator.create(username, email(username), name, groupName);
+      accountsByEmail.put(account.email, account);
+      return account;
+    }
+
+    String emailToName(String email) {
+      if (accountsByEmail.containsKey(email)) {
+        return accountsByEmail.get(email).fullName;
+      }
+      return email;
+    }
+
+    protected void addReviewers(PushOneCommit.Result r) throws Exception {
+      ReviewInput in =
+          ReviewInput.noScore()
+              .reviewer(reviewer.email)
+              .reviewer(reviewerByEmail)
+              .reviewer(ccer.email, ReviewerState.CC, false)
+              .reviewer(ccerByEmail, ReviewerState.CC, false);
+      ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+      supportReviewersByEmail = true;
+      if (result.reviewers.values().stream().anyMatch(v -> v.error != null)) {
+        supportReviewersByEmail = false;
+        in =
+            ReviewInput.noScore()
+                .reviewer(reviewer.email)
+                .reviewer(ccer.email, ReviewerState.CC, false);
+        result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+      }
+      Truth.assertThat(result.reviewers.values().stream().allMatch(v -> v.error == null)).isTrue();
+    }
+  }
+
+  protected interface PushOptionGenerator {
+    List<String> pushOptions(StagedUsers users);
+  }
+
+  protected class StagedPreChange extends StagedUsers {
+    public final TestRepository<?> repo;
+    protected final PushOneCommit.Result result;
+    public final String changeId;
+
+    StagedPreChange(String ref) throws Exception {
+      this(ref, null);
+    }
+
+    StagedPreChange(String ref, @Nullable PushOptionGenerator pushOptionGenerator)
+        throws Exception {
+      super();
+      List<String> pushOptions = null;
+      if (pushOptionGenerator != null) {
+        pushOptions = pushOptionGenerator.pushOptions(this);
+      }
+      if (pushOptions != null) {
+        ref = ref + '%' + Joiner.on(',').join(pushOptions);
+      }
+      setApiUser(owner);
+      repo = cloneProject(project, owner);
+      PushOneCommit push = pushFactory.create(db, owner.getIdent(), repo);
+      result = push.to(ref);
+      result.assertOkStatus();
+      changeId = result.getChangeId();
+    }
+  }
+
+  protected StagedPreChange stagePreChange(String ref) throws Exception {
+    return new StagedPreChange(ref);
+  }
+
+  protected StagedPreChange stagePreChange(
+      String ref, @Nullable PushOptionGenerator pushOptionGenerator) throws Exception {
+    return new StagedPreChange(ref, pushOptionGenerator);
+  }
+
+  protected class StagedChange extends StagedPreChange {
+    StagedChange(String ref) throws Exception {
+      super(ref);
+
+      setApiUser(starrer);
+      gApi.accounts().self().starChange(result.getChangeId());
+
+      setApiUser(owner);
+      addReviewers(result);
+      sender.clear();
+    }
+  }
+
+  protected StagedChange stageReviewableChange() throws Exception {
+    return new StagedChange("refs/for/master");
+  }
+
+  protected StagedChange stageWipChange() throws Exception {
+    return new StagedChange("refs/for/master%wip");
+  }
+
+  protected StagedChange stageReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).setWorkInProgress();
+    return sc;
+  }
+
+  protected StagedChange stageAbandonedReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).abandon();
+    sender.clear();
+    return sc;
+  }
+
+  protected StagedChange stageAbandonedReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).abandon();
+    sender.clear();
+    return sc;
+  }
+
+  protected StagedChange stageAbandonedWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).abandon();
+    sender.clear();
+    return sc;
+  }
+}
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 b6547ef..987cb97 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
@@ -191,7 +191,7 @@
   static final Scope REQUEST =
       new Scope() {
         @Override
-        public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
+        public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
           return new Provider<T>() {
             @Override
             public T get() {
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 20ae2d1..a8f7767 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
@@ -14,25 +14,27 @@
 
 package com.google.gerrit.acceptance;
 
-import static com.google.common.base.Preconditions.checkArgument;
 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.common.Nullable;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
 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.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.group.GroupsUpdate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.ServerInitiated;
 import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gerrit.testutil.SshMode;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.jcraft.jsch.JSch;
 import com.jcraft.jsch.JSchException;
@@ -40,97 +42,118 @@
 import java.io.ByteArrayOutputStream;
 import java.io.UnsupportedEncodingException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 @Singleton
 public class AccountCreator {
   private final Map<String, TestAccount> accounts;
 
   private final SchemaFactory<ReviewDb> reviewDbProvider;
+  private final Sequences sequences;
+  private final AccountsUpdate.Server accountsUpdate;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final GroupCache groupCache;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final SshKeyCache sshKeyCache;
-  private final AccountCache accountCache;
-  private final AccountByEmailCache byEmailCache;
   private final ExternalIdsUpdate.Server externalIdsUpdate;
+  private final boolean sshEnabled;
 
   @Inject
   AccountCreator(
       SchemaFactory<ReviewDb> schema,
+      Sequences sequences,
+      AccountsUpdate.Server accountsUpdate,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       GroupCache groupCache,
+      @ServerInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       SshKeyCache sshKeyCache,
-      AccountCache accountCache,
-      AccountByEmailCache byEmailCache,
-      ExternalIdsUpdate.Server externalIdsUpdate) {
+      ExternalIdsUpdate.Server externalIdsUpdate,
+      @SshEnabled boolean sshEnabled) {
     accounts = new HashMap<>();
     reviewDbProvider = schema;
+    this.sequences = sequences;
+    this.accountsUpdate = accountsUpdate;
     this.authorizedKeys = authorizedKeys;
     this.groupCache = groupCache;
+    this.groupsUpdateProvider = groupsUpdateProvider;
     this.sshKeyCache = sshKeyCache;
-    this.accountCache = accountCache;
-    this.byEmailCache = byEmailCache;
     this.externalIdsUpdate = externalIdsUpdate;
+    this.sshEnabled = sshEnabled;
   }
 
   public synchronized TestAccount create(
-      String username, String email, String fullName, String... groups) throws Exception {
+      @Nullable String username,
+      @Nullable String email,
+      @Nullable String fullName,
+      String... groupNames)
+      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());
+      Account.Id id = new Account.Id(sequences.nextAccountId());
 
       List<ExternalId> extIds = new ArrayList<>(2);
-      String httpPass = "http-pass";
-      extIds.add(ExternalId.createUsername(username, id, httpPass));
+      String httpPass = null;
+      if (username != null) {
+        httpPass = "http-pass";
+        extIds.add(ExternalId.createUsername(username, id, httpPass));
+      }
 
       if (email != null) {
         extIds.add(ExternalId.createEmail(id, email));
       }
-      externalIdsUpdate.create().insert(db, extIds);
+      externalIdsUpdate.create().insert(extIds);
 
-      Account a = new Account(id, TimeUtil.nowTs());
-      a.setFullName(fullName);
-      a.setPreferredEmail(email);
-      db.accounts().insert(Collections.singleton(a));
+      accountsUpdate
+          .create()
+          .insert(
+              id,
+              a -> {
+                a.setFullName(fullName);
+                a.setPreferredEmail(email);
+              });
 
-      if (groups != null) {
-        for (String n : groups) {
+      if (groupNames != null) {
+        for (String n : groupNames) {
           AccountGroup.NameKey k = new AccountGroup.NameKey(n);
-          AccountGroup g = groupCache.get(k);
-          checkArgument(g != null, "group not found: %s", n);
-          AccountGroupMember m = new AccountGroupMember(new AccountGroupMember.Key(id, g.getId()));
-          db.accountGroupMembers().insert(Collections.singleton(m));
+          Optional<InternalGroup> group = groupCache.get(k);
+          if (!group.isPresent()) {
+            throw new NoSuchGroupException(n);
+          }
+          groupsUpdateProvider.get().addGroupMember(db, group.get().getGroupUUID(), id);
         }
       }
 
       KeyPair sshKey = null;
-      if (SshMode.useSsh()) {
+      if (sshEnabled && username != null) {
         sshKey = genSshKey();
         authorizedKeys.addKey(id, publicKey(sshKey, email));
         sshKeyCache.evict(username);
       }
 
-      accountCache.evict(id);
-      accountCache.evictByUsername(username);
-      byEmailCache.evict(email);
-
       account = new TestAccount(id, username, email, fullName, sshKey, httpPass);
-      accounts.put(username, account);
+      if (username != null) {
+        accounts.put(username, account);
+      }
       return account;
     }
   }
 
-  public TestAccount create(String username, String group) throws Exception {
+  public TestAccount create(@Nullable String username, String group) throws Exception {
     return create(username, null, username, group);
   }
 
-  public TestAccount create(String username) throws Exception {
+  public TestAccount create() throws Exception {
+    return create(null);
+  }
+
+  public TestAccount create(@Nullable String username) throws Exception {
     return create(username, null, username, (String[]) null);
   }
 
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index 4c3e021..286b045 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -24,7 +24,7 @@
   private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
 
   @Override
-  public void onChangeIndexed(int id) {
+  public void onChangeIndexed(String projectName, int id) {
     countsByChange.incrementAndGet(id);
   }
 
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
index 9b77411..e9e8794 100644
--- 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
@@ -59,8 +59,7 @@
     }
   }
 
-  public EventRecorder(
-      DynamicSet<UserScopedEventListener> eventListeners, final IdentifiedUser user) {
+  public EventRecorder(DynamicSet<UserScopedEventListener> eventListeners, IdentifiedUser user) {
     recordedEvents = LinkedListMultimap.create();
 
     eventListenerRegistration =
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java
index 0cc72ec..7f90c3a 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GcAssert.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -37,8 +37,7 @@
   public void assertHasPackFile(Project.NameKey... projects)
       throws RepositoryNotFoundException, IOException {
     for (Project.NameKey p : projects) {
-      assert_()
-          .withFailureMessage("Project " + p.get() + " has no pack files.")
+      assertWithMessage("Project " + p.get() + " has no pack files.")
           .that(getPackFiles(p))
           .isNotEmpty();
     }
@@ -47,10 +46,7 @@
   public void assertHasNoPackFile(Project.NameKey... projects)
       throws RepositoryNotFoundException, IOException {
     for (Project.NameKey p : projects) {
-      assert_()
-          .withFailureMessage("Project " + p.get() + " has pack files.")
-          .that(getPackFiles(p))
-          .isEmpty();
+      assertWithMessage("Project " + p.get() + " has pack files.").that(getPackFiles(p)).isEmpty();
     }
   }
 
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 b489076..eafffca 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
@@ -19,6 +19,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -26,7 +27,7 @@
 import com.google.gerrit.pgm.Daemon;
 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.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -46,7 +47,9 @@
 import java.net.InetSocketAddress;
 import java.net.URI;
 import java.nio.file.Path;
+import java.util.Arrays;
 import java.util.HashMap;
+import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.BrokenBarrierException;
 import java.util.concurrent.CyclicBarrier;
@@ -54,6 +57,7 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
 import org.apache.log4j.Level;
 import org.apache.log4j.Logger;
 import org.eclipse.jgit.lib.Config;
@@ -76,7 +80,7 @@
       return new AutoValue_GerritServer_Description(
           testDesc,
           configName,
-          !has(UseLocalDisk.class, testDesc.getTestClass()),
+          !has(UseLocalDisk.class, testDesc.getTestClass()) && !forceLocalDisk(),
           !has(NoHttpd.class, testDesc.getTestClass()),
           has(Sandboxed.class, testDesc.getTestClass()),
           has(UseSsh.class, testDesc.getTestClass()),
@@ -91,8 +95,9 @@
       return new AutoValue_GerritServer_Description(
           testDesc,
           configName,
-          testDesc.getAnnotation(UseLocalDisk.class) == null
-              && !has(UseLocalDisk.class, testDesc.getTestClass()),
+          (testDesc.getAnnotation(UseLocalDisk.class) == null
+                  && !has(UseLocalDisk.class, testDesc.getTestClass()))
+              && !forceLocalDisk(),
           testDesc.getAnnotation(NoHttpd.class) == null
               && !has(NoHttpd.class, testDesc.getTestClass()),
           testDesc.getAnnotation(Sandboxed.class) != null
@@ -125,7 +130,11 @@
 
     abstract boolean sandboxed();
 
-    abstract boolean useSsh();
+    abstract boolean useSshAnnotation();
+
+    boolean useSsh() {
+      return useSshAnnotation() && SshMode.useSsh();
+    }
 
     @Nullable
     abstract GerritConfig config();
@@ -172,6 +181,21 @@
     }
   }
 
+  private static boolean forceLocalDisk() {
+    String value = Strings.nullToEmpty(System.getenv("GERRIT_FORCE_LOCAL_DISK"));
+    if (value.isEmpty()) {
+      value = Strings.nullToEmpty(System.getProperty("gerrit.forceLocalDisk"));
+    }
+    switch (value.trim().toLowerCase(Locale.US)) {
+      case "1":
+      case "yes":
+      case "true":
+        return true;
+      default:
+        return false;
+    }
+  }
+
   /**
    * Initializes on-disk site but does not start server.
    *
@@ -227,13 +251,14 @@
    * @return started server.
    * @throws Exception
    */
-  public static GerritServer initAndStart(Description desc, Config baseConfig) throws Exception {
+  public static GerritServer initAndStart(
+      Description desc, Config baseConfig, @Nullable Module testSysModule) throws Exception {
     Path site = TempFileUtil.createTempDirectory().toPath();
     try {
       if (!desc.memory()) {
         init(desc, baseConfig, site);
       }
-      return start(desc, baseConfig, site);
+      return start(desc, baseConfig, site, testSysModule);
     } catch (Exception e) {
       TempFileUtil.recursivelyDelete(site.toFile());
       throw e;
@@ -247,36 +272,44 @@
    * @param baseConfig default config values; merged with config from {@code desc}.
    * @param site existing temporary directory for site. Required, but may be empty, for in-memory
    *     servers. For on-disk servers, assumes that {@link #init} was previously called to
-   *     initialize this directory.
+   *     initialize this directory. Can be retrieved from the returned instance via {@link
+   *     #getSitePath()}.
+   * @param testSysModule optional additional module to add to the system injector.
+   * @param additionalArgs additional command-line arguments for the daemon program; only allowed if
+   *     the test is not in-memory.
    * @return started server.
    * @throws Exception
    */
-  public static GerritServer start(Description desc, Config baseConfig, Path site)
+  public static GerritServer start(
+      Description desc,
+      Config baseConfig,
+      Path site,
+      @Nullable Module testSysModule,
+      String... additionalArgs)
       throws Exception {
     checkArgument(site != null, "site is required (even for in-memory server");
     desc.checkValidAnnotations();
     Logger.getLogger("com.google.gerrit").setLevel(Level.DEBUG);
-    final CyclicBarrier serverStarted = new CyclicBarrier(2);
-    final Daemon daemon =
+    CyclicBarrier serverStarted = new CyclicBarrier(2);
+    Daemon daemon =
         new Daemon(
-            new Runnable() {
-              @Override
-              public void run() {
-                try {
-                  serverStarted.await();
-                } catch (InterruptedException | BrokenBarrierException e) {
-                  throw new RuntimeException(e);
-                }
+            () -> {
+              try {
+                serverStarted.await();
+              } catch (InterruptedException | BrokenBarrierException e) {
+                throw new RuntimeException(e);
               }
             },
             site);
     daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
-    daemon.setEnableSshd(SshMode.useSsh());
+    daemon.setAdditionalSysModuleForTesting(testSysModule);
+    daemon.setEnableSshd(desc.useSsh());
 
     if (desc.memory()) {
+      checkArgument(additionalArgs.length == 0, "cannot pass args to in-memory server");
       return startInMemory(desc, site, baseConfig, daemon);
     }
-    return startOnDisk(desc, site, daemon, serverStarted);
+    return startOnDisk(desc, site, daemon, serverStarted, additionalArgs);
   }
 
   private static GerritServer startInMemory(
@@ -289,6 +322,7 @@
     cfg.setBoolean("httpd", null, "requestLog", false);
     cfg.setBoolean("sshd", null, "requestLog", false);
     cfg.setBoolean("index", "lucene", "testInmemory", true);
+    cfg.setBoolean("index", null, "onlineUpgrade", false);
     cfg.setString("gitweb", null, "cgi", "");
     daemon.setEnableHttpd(desc.httpd());
     daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0));
@@ -299,18 +333,25 @@
   }
 
   private static GerritServer startOnDisk(
-      Description desc, Path site, Daemon daemon, CyclicBarrier serverStarted) throws Exception {
+      Description desc,
+      Path site,
+      Daemon daemon,
+      CyclicBarrier serverStarted,
+      String[] additionalArgs)
+      throws Exception {
     checkNotNull(site);
     ExecutorService daemonService = Executors.newSingleThreadExecutor();
+    String[] args =
+        Stream.concat(
+                Stream.of(
+                    "-d", site.toString(), "--headless", "--console-log", "--show-stack-trace"),
+                Arrays.stream(additionalArgs))
+            .toArray(String[]::new);
     @SuppressWarnings("unused")
     Future<?> possiblyIgnoredError =
         daemonService.submit(
             () -> {
-              int rc =
-                  daemon.main(
-                      new String[] {
-                        "-d", site.toString(), "--headless", "--console-log", "--show-stack-trace",
-                      });
+              int rc = daemon.main(args);
               if (rc != 0) {
                 System.err.println("Failed to start Gerrit daemon");
                 serverStarted.reset();
@@ -346,6 +387,9 @@
     cfg.setInt("sshd", null, "commandStartThreads", 1);
     cfg.setInt("receive", null, "threadPoolSize", 1);
     cfg.setInt("index", null, "threads", 1);
+    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+
+    NoteDbMode.newNotesMigrationFromEnv().setConfigValues(cfg);
   }
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
@@ -354,6 +398,7 @@
         new FactoryModule() {
           @Override
           protected void configure() {
+            bindConstant().annotatedWith(SshEnabled.class).to(daemon.getEnableSshd());
             bind(AccountCreator.class);
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
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 7e27e67..c9a474f 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
@@ -56,7 +56,7 @@
   private static final AtomicInteger testRepoCount = new AtomicInteger();
   private static final int TEST_REPO_WINDOW_DAYS = 2;
 
-  public static void initSsh(final TestAccount a) {
+  public static void initSsh(TestAccount a) {
     final Properties config = new Properties();
     config.put("StrictHostKeyChecking", "no");
     JSch.setConfig(config);
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 551c26b..0f30fa2 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
@@ -38,7 +38,6 @@
 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;
@@ -78,7 +77,7 @@
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     bind(DataSourceType.class).to(InMemoryH2Type.class);
 
-    bind(NotesMigration.class).to(TestNotesMigration.class);
+    install(new NotesMigration.Module());
     TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
         new TypeLiteral<SchemaFactory<ReviewDb>>() {};
     bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
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 0977e24..e2e29c9 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
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.common.collect.ImmutableSetMultimap;
 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.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -29,17 +30,19 @@
 import com.google.gerrit.server.RequestCleanup;
 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.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.UploadPackInitializer;
 import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.git.validators.UploadValidators;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 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.ProjectState;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -95,7 +98,7 @@
   private static final Scope REQUEST =
       new Scope() {
         @Override
-        public <T> Provider<T> scope(final Key<T> key, final Provider<T> creator) {
+        public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
           return new Provider<T>() {
             @Override
             public T get() {
@@ -206,69 +209,78 @@
   }
 
   private static class Upload implements UploadPackFactory<Context> {
-    private final Provider<ReviewDb> dbProvider;
     private final Provider<CurrentUser> userProvider;
-    private final TagCache tagCache;
-    @Nullable private final SearchingChangeCacheImpl changeCache;
-    private final ProjectControl.GenericFactory projectControlFactory;
-    private final ChangeNotes.Factory changeNotesFactory;
+    private final VisibleRefFilter.Factory refFilterFactory;
     private final TransferConfig transferConfig;
+    private final DynamicSet<UploadPackInitializer> uploadPackInitializers;
     private final DynamicSet<PreUploadHook> preUploadHooks;
     private final UploadValidators.Factory uploadValidatorsFactory;
     private final ThreadLocalRequestContext threadContext;
+    private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
 
     @Inject
     Upload(
-        Provider<ReviewDb> dbProvider,
         Provider<CurrentUser> userProvider,
-        TagCache tagCache,
-        @Nullable SearchingChangeCacheImpl changeCache,
-        ProjectControl.GenericFactory projectControlFactory,
-        ChangeNotes.Factory changeNotesFactory,
+        VisibleRefFilter.Factory refFilterFactory,
         TransferConfig transferConfig,
+        DynamicSet<UploadPackInitializer> uploadPackInitializers,
         DynamicSet<PreUploadHook> preUploadHooks,
         UploadValidators.Factory uploadValidatorsFactory,
-        ThreadLocalRequestContext threadContext) {
-      this.dbProvider = dbProvider;
+        ThreadLocalRequestContext threadContext,
+        ProjectCache projectCache,
+        PermissionBackend permissionBackend) {
       this.userProvider = userProvider;
-      this.tagCache = tagCache;
-      this.changeCache = changeCache;
-      this.projectControlFactory = projectControlFactory;
-      this.changeNotesFactory = changeNotesFactory;
+      this.refFilterFactory = refFilterFactory;
       this.transferConfig = transferConfig;
+      this.uploadPackInitializers = uploadPackInitializers;
       this.preUploadHooks = preUploadHooks;
       this.uploadValidatorsFactory = uploadValidatorsFactory;
       this.threadContext = threadContext;
+      this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
     }
 
     @Override
-    public UploadPack create(Context req, final Repository repo)
-        throws ServiceNotAuthorizedException {
+    public UploadPack create(Context req, Repository repo) throws ServiceNotAuthorizedException {
       // Set the request context, but don't bother unsetting, since we don't
       // have an easy way to run code when this instance is done being used.
       // Each operation is run in its own thread, so we don't need to recover
       // its original context anyway.
       threadContext.setContext(req);
       current.set(req);
-      try {
-        ProjectControl ctl = projectControlFactory.controlFor(req.project, userProvider.get());
-        if (!ctl.canRunUploadPack()) {
-          throw new ServiceNotAuthorizedException();
-        }
 
-        UploadPack up = new UploadPack(repo);
-        up.setPackConfig(transferConfig.getPackConfig());
-        up.setTimeout(transferConfig.getTimeout());
-        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"));
-        up.setPreUploadHook(PreUploadHookChain.newChain(hooks));
-        return up;
-      } catch (NoSuchProjectException | IOException e) {
+      try {
+        permissionBackend
+            .user(userProvider)
+            .project(req.project)
+            .check(ProjectPermission.RUN_UPLOAD_PACK);
+      } catch (AuthException e) {
+        throw new ServiceNotAuthorizedException();
+      } catch (PermissionBackendException e) {
         throw new RuntimeException(e);
       }
+
+      ProjectState projectState;
+      try {
+        projectState = projectCache.checkedGet(req.project);
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      }
+      if (projectState == null) {
+        throw new RuntimeException("can't load project state for " + req.project.get());
+      }
+      UploadPack up = new UploadPack(repo);
+      up.setPackConfig(transferConfig.getPackConfig());
+      up.setTimeout(transferConfig.getTimeout());
+      up.setAdvertiseRefsHook(refFilterFactory.create(projectState, repo));
+      List<PreUploadHook> hooks = Lists.newArrayList(preUploadHooks);
+      hooks.add(uploadValidatorsFactory.create(projectState.getProject(), repo, "localhost-test"));
+      up.setPreUploadHook(PreUploadHookChain.newChain(hooks));
+      for (UploadPackInitializer initializer : uploadPackInitializers) {
+        initializer.init(req.project, up);
+      }
+      return up;
     }
   }
 
@@ -280,6 +292,7 @@
     private final DynamicSet<ReceivePackInitializer> receivePackInitializers;
     private final DynamicSet<PostReceiveHook> postReceiveHooks;
     private final ThreadLocalRequestContext threadContext;
+    private final PermissionBackend permissionBackend;
 
     @Inject
     Receive(
@@ -289,7 +302,8 @@
         TransferConfig config,
         DynamicSet<ReceivePackInitializer> receivePackInitializers,
         DynamicSet<PostReceiveHook> postReceiveHooks,
-        ThreadLocalRequestContext threadContext) {
+        ThreadLocalRequestContext threadContext,
+        PermissionBackend permissionBackend) {
       this.userProvider = userProvider;
       this.projectControlFactory = projectControlFactory;
       this.factory = factory;
@@ -297,11 +311,11 @@
       this.receivePackInitializers = receivePackInitializers;
       this.postReceiveHooks = postReceiveHooks;
       this.threadContext = threadContext;
+      this.permissionBackend = permissionBackend;
     }
 
     @Override
-    public ReceivePack create(final Context req, Repository db)
-        throws ServiceNotAuthorizedException {
+    public ReceivePack create(Context req, Repository db) throws ServiceNotAuthorizedException {
       // Set the request context, but don't bother unsetting, since we don't
       // have an easy way to run code when this instance is done being used.
       // Each operation is run in its own thread, so we don't need to recover
@@ -309,15 +323,21 @@
       threadContext.setContext(req);
       current.set(req);
       try {
+        permissionBackend
+            .user(userProvider)
+            .project(req.project)
+            .check(ProjectPermission.RUN_RECEIVE_PACK);
+      } catch (AuthException e) {
+        throw new ServiceNotAuthorizedException();
+      } catch (PermissionBackendException e) {
+        throw new RuntimeException(e);
+      }
+      try {
         ProjectControl ctl = projectControlFactory.controlFor(req.project, userProvider.get());
-        if (!ctl.canRunReceivePack()) {
-          throw new ServiceNotAuthorizedException();
-        }
+        AsyncReceiveCommits arc = factory.create(ctl, db, null, ImmutableSetMultimap.of());
+        ReceivePack rp = arc.getReceivePack();
 
-        ReceiveCommits rc = factory.create(ctl, db).getReceiveCommits();
-        ReceivePack rp = rc.getReceivePack();
-
-        Capable r = rc.canUpload();
+        Capable r = arc.canUpload();
         if (r != Capable.OK) {
           throw new ServiceNotAuthorizedException();
         }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java
index c651d48..bd4f653 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfig.java
@@ -40,7 +40,7 @@
     }
     for (String section : s.getSections()) {
       for (String subsection : s.getSubsections(section)) {
-        for (String name : s.getNames(section, subsection)) {
+        for (String name : s.getNames(section, subsection, true)) {
           setStringList(
               section,
               subsection,
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
new file mode 100644
index 0000000..49c23e3
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/MergeableFileBasedConfigTest.java
@@ -0,0 +1,118 @@
+// 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.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import java.io.File;
+import java.nio.file.Files;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.util.FS;
+import org.junit.Test;
+
+public class MergeableFileBasedConfigTest {
+  @Test
+  public void mergeNull() throws Exception {
+    MergeableFileBasedConfig cfg = newConfig();
+    cfg.setString("foo", null, "bar", "value");
+    String expected = "[foo]\n\tbar = value\n";
+    assertConfig(cfg, expected);
+    cfg.merge(null);
+    assertConfig(cfg, expected);
+  }
+
+  @Test
+  public void mergeFlatConfig() throws Exception {
+    MergeableFileBasedConfig cfg = newConfig();
+    cfg.setString("foo", null, "bar1", "value1");
+    cfg.setString("foo", null, "bar2", "value2");
+    cfg.setString("foo", "sub", "bar1", "value1");
+    cfg.setString("foo", "sub", "bar2", "value2");
+
+    assertConfig(
+        cfg,
+        "[foo]\n"
+            + "\tbar1 = value1\n"
+            + "\tbar2 = value2\n"
+            + "[foo \"sub\"]\n"
+            + "\tbar1 = value1\n"
+            + "\tbar2 = value2\n");
+
+    Config toMerge = new Config();
+    toMerge.setStringList("foo", null, "bar2", ImmutableList.of("merge1", "merge2"));
+    toMerge.setStringList("foo", "sub", "bar2", ImmutableList.of("merge1", "merge2"));
+    cfg.merge(toMerge);
+
+    assertConfig(
+        cfg,
+        "[foo]\n"
+            + "\tbar1 = value1\n"
+            + "\tbar2 = merge1\n"
+            + "\tbar2 = merge2\n"
+            + "[foo \"sub\"]\n"
+            + "\tbar1 = value1\n"
+            + "\tbar2 = merge1\n"
+            + "\tbar2 = merge2\n");
+  }
+
+  @Test
+  public void mergeStackedConfig() throws Exception {
+    MergeableFileBasedConfig cfg = newConfig();
+    cfg.setString("foo", null, "bar1", "value1");
+    cfg.setString("foo", null, "bar2", "value2");
+    cfg.setString("foo", "sub", "bar1", "value1");
+    cfg.setString("foo", "sub", "bar2", "value2");
+
+    assertConfig(
+        cfg,
+        "[foo]\n"
+            + "\tbar1 = value1\n"
+            + "\tbar2 = value2\n"
+            + "[foo \"sub\"]\n"
+            + "\tbar1 = value1\n"
+            + "\tbar2 = value2\n");
+
+    Config base = new Config();
+    Config toMerge = new Config(base);
+    base.setStringList("foo", null, "bar2", ImmutableList.of("merge1", "merge2"));
+    base.setStringList("foo", "sub", "bar2", ImmutableList.of("merge1", "merge2"));
+    cfg.merge(toMerge);
+
+    assertConfig(
+        cfg,
+        "[foo]\n"
+            + "\tbar1 = value1\n"
+            + "\tbar2 = merge1\n"
+            + "\tbar2 = merge2\n"
+            + "[foo \"sub\"]\n"
+            + "\tbar1 = value1\n"
+            + "\tbar2 = merge1\n"
+            + "\tbar2 = merge2\n");
+  }
+
+  private MergeableFileBasedConfig newConfig() throws Exception {
+    File f = File.createTempFile(getClass().getSimpleName(), ".config");
+    f.deleteOnExit();
+    return new MergeableFileBasedConfig(f, FS.detect());
+  }
+
+  private void assertConfig(MergeableFileBasedConfig cfg, String expected) throws Exception {
+    assertThat(cfg.toText()).isEqualTo(expected);
+    cfg.save();
+    assertThat(new String(Files.readAllBytes(cfg.getFile().toPath()), UTF_8)).isEqualTo(expected);
+  }
+}
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 c7d52fe..57d39c0 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
@@ -16,9 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.assertEquals;
 
 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;
@@ -29,15 +31,19 @@
 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.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 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.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
 import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -123,7 +129,7 @@
     }
   }
 
-  private static AtomicInteger CHANGE_ID_COUNTER = new AtomicInteger();
+  private static final AtomicInteger CHANGE_ID_COUNTER = new AtomicInteger();
 
   private static String nextChangeId() {
     // Tests use a variety of mechanisms for setting temporary timestamps, so we can't guarantee
@@ -138,6 +144,7 @@
   private final ChangeNotes.Factory notesFactory;
   private final ApprovalsUtil approvalsUtil;
   private final Provider<InternalChangeQuery> queryProvider;
+  private final NotesMigration notesMigration;
   private final ReviewDb db;
   private final TestRepository<?> testRepo;
 
@@ -155,6 +162,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo)
@@ -163,6 +171,7 @@
         notesFactory,
         approvalsUtil,
         queryProvider,
+        notesMigration,
         db,
         i,
         testRepo,
@@ -176,6 +185,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -185,6 +195,7 @@
         notesFactory,
         approvalsUtil,
         queryProvider,
+        notesMigration,
         db,
         i,
         testRepo,
@@ -199,6 +210,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -210,6 +222,7 @@
         notesFactory,
         approvalsUtil,
         queryProvider,
+        notesMigration,
         db,
         i,
         testRepo,
@@ -224,13 +237,24 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
       @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);
+    this(
+        notesFactory,
+        approvalsUtil,
+        queryProvider,
+        notesMigration,
+        db,
+        i,
+        testRepo,
+        subject,
+        files,
+        null);
   }
 
   @AssistedInject
@@ -238,6 +262,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -250,6 +275,7 @@
         notesFactory,
         approvalsUtil,
         queryProvider,
+        notesMigration,
         db,
         i,
         testRepo,
@@ -262,6 +288,7 @@
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
+      NotesMigration notesMigration,
       ReviewDb db,
       PersonIdent i,
       TestRepository<?> testRepo,
@@ -274,6 +301,7 @@
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.queryProvider = queryProvider;
+    this.notesMigration = notesMigration;
     this.subject = subject;
     this.files = files;
     this.changeId = changeId;
@@ -332,7 +360,7 @@
     return new Result(ref, pushHead(testRepo, ref, tag != null, force, pushOptions), c, subject);
   }
 
-  public void setTag(final Tag tag) {
+  public void setTag(Tag tag) {
     this.tag = tag;
   }
 
@@ -392,16 +420,36 @@
     public void assertChange(
         Change.Status expectedStatus, String expectedTopic, TestAccount... expectedReviewers)
         throws OrmException {
+      assertChange(
+          expectedStatus, expectedTopic, Arrays.asList(expectedReviewers), ImmutableList.of());
+    }
+
+    public void assertChange(
+        Change.Status expectedStatus,
+        String expectedTopic,
+        List<TestAccount> expectedReviewers,
+        List<TestAccount> expectedCcs)
+        throws OrmException {
       Change c = getChange().change();
       assertThat(c.getSubject()).isEqualTo(resSubj);
       assertThat(c.getStatus()).isEqualTo(expectedStatus);
       assertThat(Strings.emptyToNull(c.getTopic())).isEqualTo(expectedTopic);
-      assertReviewers(c, expectedReviewers);
+      if (notesMigration.readChanges()) {
+        assertReviewers(c, ReviewerStateInternal.REVIEWER, expectedReviewers);
+        assertReviewers(c, ReviewerStateInternal.CC, expectedCcs);
+      } else {
+        assertReviewers(
+            c,
+            ReviewerStateInternal.REVIEWER,
+            Stream.concat(expectedReviewers.stream(), expectedCcs.stream()).collect(toList()));
+      }
     }
 
-    private void assertReviewers(Change c, TestAccount... expectedReviewers) throws OrmException {
+    private void assertReviewers(
+        Change c, ReviewerStateInternal state, List<TestAccount> expectedReviewers)
+        throws OrmException {
       Iterable<Account.Id> actualIds =
-          approvalsUtil.getReviewers(db, notesFactory.createChecked(db, c)).all();
+          approvalsUtil.getReviewers(db, notesFactory.createChecked(db, c)).byState(state);
       assertThat(actualIds)
           .containsExactlyElementsIn(Sets.newHashSet(TestAccount.ids(expectedReviewers)));
     }
@@ -426,7 +474,11 @@
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(refUpdate).isNotNull();
       assertThat(refUpdate.getStatus()).named(message(refUpdate)).isEqualTo(expectedStatus);
-      assertThat(refUpdate.getMessage()).isEqualTo(expectedMessage);
+      if (expectedMessage == null) {
+        assertThat(refUpdate.getMessage()).isNull();
+      } else {
+        assertThat(refUpdate.getMessage()).contains(expectedMessage);
+      }
     }
 
     public void assertMessage(String expectedMessage) {
@@ -435,6 +487,11 @@
       assertThat(message(refUpdate).toLowerCase()).contains(expectedMessage.toLowerCase());
     }
 
+    public void assertNotMessage(String message) {
+      RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
+      assertThat(message(refUpdate).toLowerCase()).doesNotContain(message.toLowerCase());
+    }
+
     public String getMessage() {
       RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
       assertThat(refUpdate).isNotNull();
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
index 7912c08..5209f90 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Change.Id;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.index.Schema;
 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 java.io.IOException;
 
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 e08132a..da08215 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,7 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.JSON_MAGIC;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -39,8 +39,7 @@
   }
 
   public void assertStatus(int status) throws Exception {
-    assert_()
-        .withFailureMessage(String.format("Expected status code %d", status))
+    assertWithMessage(String.format("Expected status code %d", status))
         .that(getStatusCode())
         .isEqualTo(status);
   }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshEnabled.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshEnabled.java
new file mode 100644
index 0000000..5349755
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshEnabled.java
@@ -0,0 +1,24 @@
+// 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.acceptance;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface SshEnabled {}
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java
index c433cad..b7bfff7 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/SshSession.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.jcraft.jsch.ChannelExec;
@@ -85,7 +85,7 @@
   }
 
   public void assertSuccess() {
-    assert_().withFailureMessage(getError()).that(hasError()).isFalse();
+    assertWithMessage(getError()).that(hasError()).isFalse();
   }
 
   public void assertFailure() {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index 9aa09db..a218f73 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -19,6 +19,7 @@
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -32,10 +33,15 @@
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.inject.Injector;
+import com.google.inject.Module;
 import com.google.inject.Provider;
+import java.io.File;
 import java.util.Arrays;
 import java.util.Collections;
 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.junit.Rule;
 import org.junit.rules.RuleChain;
 import org.junit.rules.TemporaryFolder;
@@ -107,8 +113,12 @@
           return new Statement() {
             @Override
             public void evaluate() throws Throwable {
-              beforeTest(description);
-              base.evaluate();
+              try {
+                beforeTest(description);
+                base.evaluate();
+              } finally {
+                afterTest();
+              }
             }
           };
         }
@@ -120,31 +130,93 @@
   protected Account.Id adminId;
 
   private GerritServer.Description serverDesc;
+  private SystemReader oldSystemReader;
 
   private void beforeTest(Description description) throws Exception {
+    // SystemReader must be overridden before creating any repos, since they read the user/system
+    // configs at initialization time, and are then stored in the RepositoryCache forever.
+    oldSystemReader = setFakeSystemReader(tempSiteDir.getRoot());
+
     serverDesc = GerritServer.Description.forTestMethod(description, configName);
     sitePaths = new SitePaths(tempSiteDir.getRoot().toPath());
     GerritServer.init(serverDesc, baseConfig, sitePaths.site_path);
   }
 
+  private static SystemReader setFakeSystemReader(File tempDir) {
+    SystemReader oldSystemReader = SystemReader.getInstance();
+    SystemReader.setInstance(
+        new SystemReader() {
+          @Override
+          public String getHostname() {
+            return oldSystemReader.getHostname();
+          }
+
+          @Override
+          public String getenv(String variable) {
+            return oldSystemReader.getenv(variable);
+          }
+
+          @Override
+          public String getProperty(String key) {
+            return oldSystemReader.getProperty(key);
+          }
+
+          @Override
+          public FileBasedConfig openUserConfig(Config parent, FS fs) {
+            return new FileBasedConfig(parent, new File(tempDir, "user.config"), FS.detect());
+          }
+
+          @Override
+          public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+            return new FileBasedConfig(parent, new File(tempDir, "system.config"), FS.detect());
+          }
+
+          @Override
+          public long getCurrentTime() {
+            return oldSystemReader.getCurrentTime();
+          }
+
+          @Override
+          public int getTimezone(long when) {
+            return oldSystemReader.getTimezone(when);
+          }
+        });
+    return oldSystemReader;
+  }
+
+  private void afterTest() throws Exception {
+    SystemReader.setInstance(oldSystemReader);
+    oldSystemReader = null;
+  }
+
   protected ServerContext startServer() throws Exception {
-    return new ServerContext(startImpl());
+    return startServer(null);
+  }
+
+  protected ServerContext startServer(@Nullable Module testSysModule, String... additionalArgs)
+      throws Exception {
+    return new ServerContext(startImpl(testSysModule, additionalArgs));
   }
 
   protected void assertServerStartupFails() throws Exception {
-    try (GerritServer server = startImpl()) {
+    try (GerritServer server = startImpl(null)) {
       fail("expected server startup to fail");
     } catch (GerritServer.StartupException e) {
       // Expected.
     }
   }
 
-  private GerritServer startImpl() throws Exception {
-    return GerritServer.start(serverDesc, baseConfig, sitePaths.site_path);
+  private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
+      throws Exception {
+    return GerritServer.start(
+        serverDesc, baseConfig, sitePaths.site_path, testSysModule, additionalArgs);
   }
 
   protected static void runGerrit(String... args) throws Exception {
-    assertThat(GerritLauncher.mainImpl(args))
+    // Use invokeProgram with the current classloader, rather than mainImpl, which would create a
+    // new classloader. This is necessary so that static state, particularly the SystemReader, is
+    // shared with the test method.
+    assertThat(GerritLauncher.invokeProgram(StandaloneSiteTest.class.getClassLoader(), args))
         .named("gerrit.war " + Arrays.stream(args).collect(joining(" ")))
         .isEqualTo(0);
   }
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 5117328..7acb135 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
@@ -16,12 +16,15 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.net.InetAddresses;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.mail.Address;
 import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
 import java.util.Arrays;
 import java.util.List;
+import org.apache.http.client.utils.URIBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 
 public class TestAccount {
@@ -29,10 +32,6 @@
     return accounts.stream().map(a -> a.id).collect(toList());
   }
 
-  public static List<Account.Id> ids(TestAccount... accounts) {
-    return ids(Arrays.asList(accounts));
-  }
-
   public static List<String> names(List<TestAccount> accounts) {
     return accounts.stream().map(a -> a.fullName).collect(toList());
   }
@@ -77,12 +76,13 @@
   }
 
   public String getHttpUrl(GerritServer server) {
-    return String.format(
-        "http://%s:%s@%s:%d",
-        username,
-        httpPassword,
-        server.getHttpAddress().getAddress().getHostAddress(),
-        server.getHttpAddress().getPort());
+    InetSocketAddress addr = server.getHttpAddress();
+    return new URIBuilder()
+        .setScheme("http")
+        .setUserInfo(username, httpPassword)
+        .setHost(InetAddresses.toUriString(addr.getAddress()))
+        .setPort(addr.getPort())
+        .toString();
   }
 
   public Account.Id getId() {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java
index 739d4f5..86f3c03 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestProjectInput.java
@@ -45,6 +45,10 @@
 
   InheritableBoolean requireChangeId() default InheritableBoolean.INHERIT;
 
+  InheritableBoolean enableSignedPush() default InheritableBoolean.INHERIT;
+
+  InheritableBoolean requireSignedPush() default InheritableBoolean.INHERIT;
+
   // Fields specific to acceptance test behavior.
 
   /** Username to use for initial clone, passed to {@link AccountCreator}. */
diff --git a/gerrit-acceptance-tests/BUILD b/gerrit-acceptance-tests/BUILD
index 8317482..6696184 100644
--- a/gerrit-acceptance-tests/BUILD
+++ b/gerrit-acceptance-tests/BUILD
@@ -1,16 +1,17 @@
 load("@rules_java//java:defs.bzl", "java_library")
 
+RESOURCES = glob(["src/test/resources/**/*"])
+
 java_library(
     name = "lib",
     testonly = 1,
     srcs = ["src/test/java/com/google/gerrit/acceptance/Dummy.java"],
+    resources = RESOURCES,
     visibility = ["//visibility:public"],
     exports = [
         "//gerrit-acceptance-framework:lib",
         "//gerrit-common:annotations",
         "//gerrit-common:server",
-        "//gerrit-elasticsearch:elasticsearch",
-        "//gerrit-elasticsearch:elasticsearch_test_utils",
         "//gerrit-extension-api:api",
         "//gerrit-gpg:testutil",
         "//gerrit-httpd:httpd",
@@ -20,9 +21,11 @@
         "//gerrit-pgm:pgm",
         "//gerrit-pgm:util",
         "//gerrit-reviewdb:server",
+        "//gerrit-server:metrics",
+        "//gerrit-server:prolog-common",
+        "//gerrit-server:receive",
         "//gerrit-server:server",
         "//gerrit-server:testutil",
-        "//gerrit-server/src/main/prolog:common",
         "//gerrit-sshd:sshd",
         "//gerrit-test-util:test_util",
         "//lib:args4j",
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 83bb7be..ac71e54 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
@@ -16,7 +16,8 @@
 
 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.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 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;
@@ -26,32 +27,52 @@
 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.account.externalids.ExternalId.SCHEME_GPGKEY;
 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.testutil.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.io.BaseEncoding;
+import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AccountCreator;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 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.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
 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.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -59,33 +80,49 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.testutil.TestKey;
 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.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.account.AccountByEmailCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.account.WatchConfig;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gerrit.server.project.RefPattern;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 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 java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
@@ -93,10 +130,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.ObjectReader;
 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.transport.PushCertificateIdent;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.treewalk.TreeWalk;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Before;
@@ -114,20 +157,64 @@
 
   @Inject private AllUsersName allUsers;
 
-  @Inject private AccountByEmailCache byEmailCache;
+  @Inject private AccountsUpdate.Server accountsUpdate;
+
+  @Inject private ExternalIds externalIds;
 
   @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
 
+  @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
+
+  @Inject private DynamicSet<GitReferenceUpdatedListener> refUpdateListeners;
+
+  @Inject private Sequences seq;
+
+  @Inject private Provider<InternalAccountQuery> accountQueryProvider;
+
+  @Inject protected Emails emails;
+
+  @Inject private AccountManager accountManager;
+
+  private AccountIndexedCounter accountIndexedCounter;
+  private RegistrationHandle accountIndexEventCounterHandle;
+  private RefUpdateCounter refUpdateCounter;
+  private RegistrationHandle refUpdateCounterHandle;
   private ExternalIdsUpdate externalIdsUpdate;
   private List<ExternalId> savedExternalIds;
 
   @Before
+  public void addAccountIndexEventCounter() {
+    accountIndexedCounter = new AccountIndexedCounter();
+    accountIndexEventCounterHandle = accountIndexedListeners.add(accountIndexedCounter);
+  }
+
+  @After
+  public void removeAccountIndexEventCounter() {
+    if (accountIndexEventCounterHandle != null) {
+      accountIndexEventCounterHandle.remove();
+    }
+  }
+
+  @Before
+  public void addRefUpdateCounter() {
+    refUpdateCounter = new RefUpdateCounter();
+    refUpdateCounterHandle = refUpdateListeners.add(refUpdateCounter);
+  }
+
+  @After
+  public void removeRefUpdateCounter() {
+    if (refUpdateCounterHandle != null) {
+      refUpdateCounterHandle.remove();
+    }
+  }
+
+  @Before
   public void saveExternalIds() throws Exception {
     externalIdsUpdate = externalIdsUpdateFactory.create();
 
     savedExternalIds = new ArrayList<>();
-    savedExternalIds.addAll(getExternalIds(admin));
-    savedExternalIds.addAll(getExternalIds(user));
+    savedExternalIds.addAll(externalIds.byAccount(admin.id));
+    savedExternalIds.addAll(externalIds.byAccount(user.id));
   }
 
   @After
@@ -136,9 +223,9 @@
       // savedExternalIds is null when we don't run SSH tests and the assume in
       // @Before in AbstractDaemonTest prevents this class' @Before method from
       // being executed.
-      externalIdsUpdate.delete(db, getExternalIds(admin));
-      externalIdsUpdate.delete(db, getExternalIds(user));
-      externalIdsUpdate.insert(db, savedExternalIds);
+      externalIdsUpdate.delete(externalIds.byAccount(admin.id));
+      externalIdsUpdate.delete(externalIds.byAccount(user.id));
+      externalIdsUpdate.insert(savedExternalIds);
     }
   }
 
@@ -154,10 +241,6 @@
     }
   }
 
-  private Collection<ExternalId> getExternalIds(TestAccount account) throws Exception {
-    return accountCache.get(account.getId()).getExternalIds();
-  }
-
   @After
   public void deleteGpgKeys() throws Exception {
     String ref = REFS_GPG_KEYS;
@@ -165,8 +248,7 @@
       if (repo.getRefDatabase().exactRef(ref) != null) {
         RefUpdate ru = repo.updateRef(ref);
         ru.setForceUpdate(true);
-        assert_()
-            .withFailureMessage("Failed to delete " + ref)
+        assertWithMessage("Failed to delete " + ref)
             .that(ru.delete())
             .isEqualTo(RefUpdate.Result.FORCED);
       }
@@ -174,11 +256,129 @@
   }
 
   @Test
+  public void create() throws Exception {
+    Account.Id accountId = create(2); // account creation + external ID creation
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
+        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
+        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS));
+  }
+
+  @Test
+  @UseSsh
+  public void createWithSshKeys() throws Exception {
+    Account.Id accountId = create(3); // account creation + external ID creation + adding SSH keys
+    refUpdateCounter.assertRefUpdateFor(
+        ImmutableMap.of(
+            RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
+            2,
+            RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
+            1,
+            RefUpdateCounter.projectRef(
+                allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS),
+            1));
+  }
+
+  @Test
+  public void createWithInvalidEmailAddress() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = name("test");
+    input.email = "invalid email address";
+
+    // Invalid email address should cause the creation to fail
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> gApi.accounts().create(input));
+    assertThat(thrown).hasMessageThat().isEqualTo("invalid email address");
+
+    // The account should not have been created
+    assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id(input.username).get());
+  }
+
+  private Account.Id create(int expectedAccountReindexCalls) throws Exception {
+    String name = "foo";
+    TestAccount foo = accountCreator.create(name);
+    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    assertThat(info.username).isEqualTo(name);
+    assertThat(info.name).isEqualTo(name);
+    accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
+    assertUserBranch(foo.getId(), name, null);
+    return foo.getId();
+  }
+
+  @Test
+  public void createAnonymousCoward() throws Exception {
+    TestAccount anonymousCoward = accountCreator.create();
+    accountIndexedCounter.assertReindexOf(anonymousCoward);
+    assertUserBranchWithoutAccountConfig(anonymousCoward.getId());
+  }
+
+  @Test
+  public void updateNonExistingAccount() throws Exception {
+    Account.Id nonExistingAccountId = new Account.Id(999999);
+    AtomicBoolean consumerCalled = new AtomicBoolean();
+    Account account =
+        accountsUpdate.create().update(nonExistingAccountId, a -> consumerCalled.set(true));
+    assertThat(account).isNull();
+    assertThat(consumerCalled.get()).isFalse();
+  }
+
+  @Test
+  public void updateAccountWithoutAccountConfigNoteDb() throws Exception {
+    TestAccount anonymousCoward = accountCreator.create();
+    assertUserBranchWithoutAccountConfig(anonymousCoward.getId());
+
+    String status = "OOO";
+    Account account =
+        accountsUpdate.create().update(anonymousCoward.getId(), a -> a.setStatus(status));
+    assertThat(account).isNotNull();
+    assertThat(account.getFullName()).isNull();
+    assertThat(account.getStatus()).isEqualTo(status);
+    assertUserBranch(anonymousCoward.getId(), null, status);
+  }
+
+  private void assertUserBranchWithoutAccountConfig(Account.Id accountId) throws Exception {
+    assertUserBranch(accountId, null, null);
+  }
+
+  private void assertUserBranch(
+      Account.Id accountId, @Nullable String name, @Nullable String status) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader or = repo.newObjectReader()) {
+      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
+      assertThat(ref).isNotNull();
+      RevCommit c = rw.parseCommit(ref.getObjectId());
+      long timestampDiffMs =
+          Math.abs(
+              c.getCommitTime() * 1000L
+                  - accountCache.get(accountId).getAccount().getRegisteredOn().getTime());
+      assertThat(timestampDiffMs).isAtMost(ChangeRebuilderImpl.MAX_WINDOW_MS);
+
+      // Check the 'account.config' file.
+      try (TreeWalk tw = TreeWalk.forPath(or, AccountConfig.ACCOUNT_CONFIG, c.getTree())) {
+        if (name != null || status != null) {
+          assertThat(tw).isNotNull();
+          Config cfg = new Config();
+          cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
+          assertThat(cfg.getString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_FULL_NAME))
+              .isEqualTo(name);
+          assertThat(cfg.getString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS))
+              .isEqualTo(status);
+        } else {
+          // No account properties were set, hence an 'account.config' file was not created.
+          assertThat(tw).isNull();
+        }
+      }
+    }
+  }
+
+  @Test
   public void get() throws Exception {
     AccountInfo info = gApi.accounts().id("admin").get();
     assertThat(info.name).isEqualTo("Administrator");
     assertThat(info.email).isEqualTo("admin@example.com");
     assertThat(info.username).isEqualTo("admin");
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -186,6 +386,7 @@
     AccountInfo info = gApi.accounts().id("admin").get();
     AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
     assertThat(info.name).isEqualTo(infoByIntId.name);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -195,6 +396,7 @@
 
     info = gApi.accounts().id("self").get();
     assertUser(info, admin);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -202,8 +404,11 @@
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
     gApi.accounts().id("user").setActive(false);
     assertThat(gApi.accounts().id("user").getActive()).isFalse();
+    accountIndexedCounter.assertReindexOf(user);
+
     gApi.accounts().id("user").setActive(true);
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    accountIndexedCounter.assertReindexOf(user);
   }
 
   @Test
@@ -231,21 +436,33 @@
   public void starUnstarChange() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
+    refUpdateCounter.clear();
+
     gApi.accounts().self().starChange(triplet);
     ChangeInfo change = info(triplet);
     assertThat(change.starred).isTrue();
     assertThat(change.stars).contains(DEFAULT_LABEL);
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
 
     gApi.accounts().self().unstarChange(triplet);
     change = info(triplet);
     assertThat(change.starred).isNull();
     assertThat(change.stars).isNull();
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
   public void starUnstarChangeWithLabels() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
+    refUpdateCounter.clear();
+
     assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
     assertThat(gApi.accounts().self().getStarredChanges()).isEmpty();
 
@@ -264,6 +481,9 @@
     assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
     assertThat(starredChange.starred).isTrue();
     assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
 
     gApi.accounts()
         .self()
@@ -280,6 +500,11 @@
     assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
     assertThat(starredChange.starred).isNull();
     assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+
+    accountIndexedCounter.assertNoReindex();
 
     setApiUser(user);
     exception.expect(AuthException.class);
@@ -302,6 +527,17 @@
   }
 
   @Test
+  public void deleteStarLabelsFromChangeWithoutStarLabels() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
+
+    gApi.accounts().self().setStars(triplet, new StarsInput());
+
+    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
+  }
+
+  @Test
   public void starWithDefaultAndIgnoreLabel() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -319,14 +555,16 @@
   }
 
   @Test
-  public void ignoreChange() throws Exception {
+  public void ignoreChangeBySetStars() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    accountIndexedCounter.clear();
+
     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);
@@ -340,6 +578,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -360,6 +599,7 @@
     Message message = messages.get(0);
     assertThat(message.rcpt()).containsExactly(user.emailAddress);
     assertMailReplyTo(message, admin.email);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -374,24 +614,18 @@
 
     List<AccountInfo> emptyResult = gApi.accounts().suggestAccounts("unknown").get();
     assertThat(emptyResult).isEmpty();
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
   public void addEmail() throws Exception {
-    List<String> emails =
-        ImmutableList.of(
-            "new.email@example.com",
-            "new.email@example.systems",
-
-            // Not in the list of TLDs but added to override in OutgoingEmailValidator
-            "new.email@example.local");
+    List<String> emails = ImmutableList.of("new.email@example.com", "new.email@example.systems");
     Set<String> currentEmails = getEmails();
     for (String email : emails) {
       assertThat(currentEmails).doesNotContain(email);
-      EmailInput input = new EmailInput();
-      input.email = email;
-      input.noConfirmation = true;
+      EmailInput input = newEmailInput(email);
       gApi.accounts().self().addEmail(input);
+      accountIndexedCounter.assertReindexOf(admin);
     }
 
     resetCurrentApiUser();
@@ -412,11 +646,9 @@
             "@example.com",
 
             // Non-supported TLD  (see tlds-alpha-by-domain.txt)
-            "new.email@example.blog");
+            "new.email@example.africa");
     for (String email : emails) {
-      EmailInput input = new EmailInput();
-      input.email = email;
-      input.noConfirmation = true;
+      EmailInput input = newEmailInput(email);
       try {
         gApi.accounts().self().addEmail(input);
         fail("Expected BadRequestException for invalid email address: " + email);
@@ -424,26 +656,127 @@
         assertThat(e).hasMessageThat().isEqualTo("invalid email address");
       }
     }
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void cannotAddNonConfirmedEmailWithoutModifyAccountPermission() throws Exception {
+    TestAccount account = accountCreator.create(name("user"));
+    EmailInput input = newEmailInput("test@test.com");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.accounts().id(account.username).addEmail(input);
+  }
+
+  @Test
+  public void cannotAddEmailAddressUsedByAnotherAccount() throws Exception {
+    String email = "new.email@example.com";
+    EmailInput input = newEmailInput(email);
+    gApi.accounts().self().addEmail(input);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Identity 'mailto:" + email + "' in use by another account");
+    gApi.accounts().id(user.username).addEmail(input);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "auth.registerEmailPrivateKey",
+      value = "HsOc6l+2lhS9G7sE/RsnS7Z6GJjdRDX14co=")
+  public void addEmailSendsConfirmationEmail() throws Exception {
+    String email = "new.email@example.com";
+    EmailInput input = newEmailInput(email, false);
+    gApi.accounts().self().addEmail(input);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(new Address(email));
+  }
+
+  @Test
+  public void addEmailAndSetPreferred() throws Exception {
+    String email = "foo.bar@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = true;
+    input.preferred = true;
+    gApi.accounts().self().addEmail(input);
+
+    // Account is reindexed twice; once on adding the new email,
+    // and then again on setting the email preferred.
+    accountIndexedCounter.assertReindexOf(admin, 2);
+
+    String preferred = gApi.accounts().self().get().email;
+    assertThat(preferred).isEqualTo(email);
   }
 
   @Test
   public void deleteEmail() throws Exception {
     String email = "foo.bar@example.com";
-    EmailInput input = new EmailInput();
-    input.email = email;
-    input.noConfirmation = true;
+    EmailInput input = newEmailInput(email);
     gApi.accounts().self().addEmail(input);
 
     resetCurrentApiUser();
     assertThat(getEmails()).contains(email);
 
+    accountIndexedCounter.clear();
     gApi.accounts().self().deleteEmail(input.email);
+    accountIndexedCounter.assertReindexOf(admin);
 
     resetCurrentApiUser();
     assertThat(getEmails()).doesNotContain(email);
   }
 
   @Test
+  public void deletePreferredEmail() throws Exception {
+    String previous = gApi.accounts().self().get().email;
+    String email = "foo.bar.baz@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = true;
+    input.preferred = true;
+    gApi.accounts().self().addEmail(input);
+
+    // Account is reindexed twice; once on adding the new email,
+    // and then again on setting the email preferred.
+    accountIndexedCounter.assertReindexOf(admin, 2);
+
+    // The new preferred email is set
+    assertThat(gApi.accounts().self().get().email).isEqualTo(email);
+
+    accountIndexedCounter.clear();
+    gApi.accounts().self().deleteEmail(input.email);
+
+    // Account is reindexed twice; once on removing the new email,
+    // and then again on unsetting the email preferred.
+    accountIndexedCounter.assertReindexOf(admin, 2);
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).containsExactly(previous);
+    assertThat(gApi.accounts().self().get().email).isNull();
+  }
+
+  @Test
+  @Sandboxed
+  public void deleteAllEmails() throws Exception {
+    EmailInput input = new EmailInput();
+    input.email = "foo.bar@example.com";
+    input.noConfirmation = true;
+    gApi.accounts().self().addEmail(input);
+
+    resetCurrentApiUser();
+    Set<String> allEmails = getEmails();
+    assertThat(allEmails).hasSize(2);
+
+    for (String email : allEmails) {
+      gApi.accounts().self().deleteEmail(email);
+    }
+
+    resetCurrentApiUser();
+    assertThat(getEmails()).isEmpty();
+    assertThat(gApi.accounts().self().get().email).isNull();
+  }
+
+  @Test
   public void deleteEmailFromCustomExternalIdSchemes() throws Exception {
     String email = "foo.bar@example.com";
     String extId1 = "foo:bar";
@@ -452,7 +785,8 @@
         ImmutableList.of(
             ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id, email),
             ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email));
-    externalIdsUpdateFactory.create().insert(db, extIds);
+    externalIdsUpdateFactory.create().insert(extIds);
+    accountIndexedCounter.assertReindexOf(admin);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .containsAllOf(extId1, extId2);
@@ -461,6 +795,7 @@
     assertThat(getEmails()).contains(email);
 
     gApi.accounts().self().deleteEmail(email);
+    accountIndexedCounter.assertReindexOf(admin);
 
     resetCurrentApiUser();
     assertThat(getEmails()).doesNotContain(email);
@@ -476,6 +811,7 @@
     input.email = email;
     input.noConfirmation = true;
     gApi.accounts().id(user.id.get()).addEmail(input);
+    accountIndexedCounter.assertReindexOf(user);
 
     setApiUser(user);
     assertThat(getEmails()).contains(email);
@@ -483,36 +819,65 @@
     // admin can delete email of user
     setApiUser(admin);
     gApi.accounts().id(user.id.get()).deleteEmail(email);
+    accountIndexedCounter.assertReindexOf(user);
 
     setApiUser(user);
     assertThat(getEmails()).doesNotContain(email);
 
     // user cannot delete email of admin
     exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to delete email address");
+    exception.expectMessage("modify account not permitted");
     gApi.accounts().id(admin.id.get()).deleteEmail(admin.email);
   }
 
   @Test
-  public void lookUpFromCacheByEmail() throws Exception {
+  public void lookUpByEmail() throws Exception {
     // exact match with scheme "mailto:"
-    assertEmail(byEmailCache.get(admin.email), admin);
+    assertEmail(emails.getAccountFor(admin.email), admin);
 
     // exact match with other scheme
     String email = "foo.bar@example.com";
     externalIdsUpdateFactory
         .create()
-        .insert(db, ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email));
-    assertEmail(byEmailCache.get(email), admin);
+        .insert(ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email));
+    assertEmail(emails.getAccountFor(email), admin);
 
     // wrong case doesn't match
-    assertThat(byEmailCache.get(admin.email.toUpperCase(Locale.US))).isEmpty();
+    assertThat(emails.getAccountFor(admin.email.toUpperCase(Locale.US))).isEmpty();
 
     // prefix doesn't match
-    assertThat(byEmailCache.get(admin.email.substring(0, admin.email.indexOf('@')))).isEmpty();
+    assertThat(emails.getAccountFor(admin.email.substring(0, admin.email.indexOf('@')))).isEmpty();
 
     // non-existing doesn't match
-    assertThat(byEmailCache.get("non-existing@example.com")).isEmpty();
+    assertThat(emails.getAccountFor("non-existing@example.com")).isEmpty();
+
+    // lookup several accounts by email at once
+    ImmutableSetMultimap<String, Account.Id> byEmails =
+        emails.getAccountsFor(admin.email, user.email);
+    assertEmail(byEmails.get(admin.email), admin);
+    assertEmail(byEmails.get(user.email), user);
+  }
+
+  @Test
+  public void lookUpByPreferredEmail() throws Exception {
+    // create an inconsistent account that has a preferred email without external ID
+    String prefix = "foo.preferred";
+    String prefEmail = prefix + "@example.com";
+    TestAccount foo = accountCreator.create(name("foo"));
+    accountsUpdate.create().update(foo.id, a -> a.setPreferredEmail(prefEmail));
+
+    // verify that the account is still found when using the preferred email to lookup the account
+    ImmutableSet<Account.Id> accountsByPrefEmail = emails.getAccountFor(prefEmail);
+    assertThat(accountsByPrefEmail).hasSize(1);
+    assertThat(Iterables.getOnlyElement(accountsByPrefEmail)).isEqualTo(foo.id);
+
+    // look up by email prefix doesn't find the account
+    accountsByPrefEmail = emails.getAccountFor(prefix);
+    assertThat(accountsByPrefEmail).isEmpty();
+
+    // look up by other case doesn't find the account
+    accountsByPrefEmail = emails.getAccountFor(prefEmail.toUpperCase(Locale.US));
+    assertThat(accountsByPrefEmail).isEmpty();
   }
 
   @Test
@@ -524,17 +889,41 @@
       admin.status = status;
       info = gApi.accounts().self().get();
       assertUser(info, admin);
+      accountIndexedCounter.assertReindexOf(admin);
     }
   }
 
   @Test
-  public void fetchUserBranch() throws Exception {
-    // change something in the user preferences to ensure that the user branch
-    // is created
+  public void setName() throws Exception {
+    gApi.accounts().self().setName("Admin McAdminface");
+    assertThat(gApi.accounts().self().get().name).isEqualTo("Admin McAdminface");
+  }
+
+  @Test
+  public void adminCanSetNameOfOtherUser() throws Exception {
+    gApi.accounts().id(user.username).setName("User McUserface");
+    assertThat(gApi.accounts().id(user.username).get().name).isEqualTo("User McUserface");
+  }
+
+  @Test
+  public void userCannotSetNameOfOtherUser() throws Exception {
     setApiUser(user);
-    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
-    gApi.accounts().self().setPreferences(input);
+    exception.expect(AuthException.class);
+    gApi.accounts().id(admin.username).setName("Admin McAdminface");
+  }
+
+  @Test
+  @Sandboxed
+  public void userCanSetNameOfOtherUserWithModifyAccountPermission() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.MODIFY_ACCOUNT);
+    gApi.accounts().id(admin.username).setName("Admin McAdminface");
+    assertThat(gApi.accounts().id(admin.username).get().name).isEqualTo("Admin McAdminface");
+  }
+
+  @Test
+  @Sandboxed
+  public void fetchUserBranch() throws Exception {
+    setApiUser(user);
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
     String userRefName = RefNames.refsUsers(user.id);
@@ -546,7 +935,7 @@
     saveProjectConfig(allUsers, cfg);
 
     // deny READ permission that is inherited from All-Projects
-    deny(allUsers, Permission.READ, ANONYMOUS_USERS, RefNames.REFS + "*");
+    deny(allUsers, RefNames.REFS + "*", Permission.READ, ANONYMOUS_USERS);
 
     // fetching user branch without READ permission fails
     try {
@@ -558,9 +947,9 @@
 
     // allow each user to read its own user branch
     grant(
-        Permission.READ,
         allUsers,
         RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        Permission.READ,
         false,
         REGISTERED_USERS);
 
@@ -575,6 +964,8 @@
     assertThat(userSelfRef).isNotNull();
     assertThat(userSelfRef.getObjectId()).isEqualTo(userRef.getObjectId());
 
+    accountIndexedCounter.assertNoReindex();
+
     // fetching user branch of another user fails
     String otherUserRefName = RefNames.refsUsers(admin.id);
     exception.expect(TransportException.class);
@@ -584,30 +975,20 @@
 
   @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();
+    accountIndexedCounter.assertReindexOf(admin);
 
     push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @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");
@@ -615,26 +996,244 @@
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
     r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
     assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
 
     push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
     r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
     assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewAndSubmit() throws Exception {
+    String userRef = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS, "out-of-office");
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
+
+    AccountInfo info = gApi.accounts().self().get();
+    assertThat(info.email).isEqualTo(admin.email);
+    assertThat(info.name).isEqualTo(admin.fullName);
+    assertThat(info.status).isEqualTo("out-of-office");
+  }
+
+  @Test
+  public void pushAccountConfigWithPrefEmailThatDoesNotExistAsExtIdToUserBranchForReviewAndSubmit()
+      throws Exception {
+    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+    String userRef = RefNames.refsUsers(foo.id);
+    accountIndexedCounter.clear();
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    String email = "some.email@example.com";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, email);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                foo.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    setApiUser(foo);
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    accountIndexedCounter.assertReindexOf(foo);
+
+    AccountInfo info = gApi.accounts().self().get();
+    assertThat(info.email).isEqualTo(email);
+    assertThat(info.name).isEqualTo(foo.fullName);
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfConfigIsInvalid()
+      throws Exception {
+    String userRef = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                "invalid config")
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format(
+            "invalid account configuration: commit '%s' has an invalid '%s' file for account '%s':"
+                + " Invalid config file %s in commit %s",
+            r.getCommit().name(),
+            AccountConfig.ACCOUNT_CONFIG,
+            admin.id,
+            AccountConfig.ACCOUNT_CONFIG,
+            r.getCommit().name()));
+    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);
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfPreferredEmailIsInvalid()
+      throws Exception {
+    String userRef = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
 
+    String noEmail = "no.email";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, noEmail);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format(
+            "invalid account configuration: invalid preferred email '%s' for account '%s'",
+            noEmail, admin.id));
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchForReviewIsRejectedOnSubmitIfOwnAccountIsDeactivated()
+      throws Exception {
+    String userRef = RefNames.refsUsers(admin.id);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("invalid account configuration: cannot deactivate own account");
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+
+  @Test
+  @Sandboxed
+  public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestAccount foo = accountCreator.create(name("foo"));
+    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
+    String userRef = RefNames.refsUsers(foo.id);
+    accountIndexedCounter.clear();
+
+    InternalGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
+    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
+    grantLabel("Code-Review", -2, 2, allUsers, userRef, false, adminGroup.getGroupUUID(), false);
+    grant(allUsers, userRef, Permission.SUBMIT, false, adminGroup.getGroupUUID());
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(MagicBranch.NEW_CHANGE + userRef);
+    r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
+    assertThat(r.getChange().change().getDest().get()).isEqualTo(userRef);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(foo);
+
+    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isFalse();
+  }
+
+  @Test
+  public void pushWatchConfigToUserBranch() throws Exception {
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
     allUsersRepo.reset("userRef");
@@ -654,6 +1253,7 @@
             WatchConfig.WATCH_CONFIG,
             wc.toText());
     push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
 
     String invalidNotifyValue = "]invalid[";
     wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY, invalidNotifyValue);
@@ -674,13 +1274,349 @@
   }
 
   @Test
+  public void pushAccountConfigToUserBranch() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS, "out-of-office");
+
+    pushFactory
+        .create(
+            db,
+            admin.getIdent(),
+            allUsersRepo,
+            "Update account config",
+            AccountConfig.ACCOUNT_CONFIG,
+            ac.toText())
+        .to(RefNames.REFS_USERS_SELF)
+        .assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
+
+    AccountInfo info = gApi.accounts().self().get();
+    assertThat(info.email).isEqualTo(admin.email);
+    assertThat(info.name).isEqualTo(admin.fullName);
+    assertThat(info.status).isEqualTo("out-of-office");
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfConfigIsInvalid() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                "invalid config")
+            .to(RefNames.REFS_USERS_SELF);
+    r.assertErrorStatus("invalid account configuration");
+    r.assertMessage(
+        String.format(
+            "commit '%s' has an invalid '%s' file for account '%s':"
+                + " Invalid config file %s in commit %s",
+            r.getCommit().name(),
+            AccountConfig.ACCOUNT_CONFIG,
+            admin.id,
+            AccountConfig.ACCOUNT_CONFIG,
+            r.getCommit().name()));
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfPreferredEmailIsInvalid() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    String noEmail = "no.email";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, noEmail);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(RefNames.REFS_USERS_SELF);
+    r.assertErrorStatus("invalid account configuration");
+    r.assertMessage(
+        String.format("invalid preferred email '%s' for account '%s'", noEmail, admin.id));
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchInvalidPreferredEmailButNotChanged() throws Exception {
+    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+    String userRef = RefNames.refsUsers(foo.id);
+
+    String noEmail = "no.email";
+    accountsUpdate.create().update(foo.id, a -> a.setPreferredEmail(noEmail));
+    accountIndexedCounter.clear();
+
+    grant(allUsers, userRef, Permission.PUSH, false, REGISTERED_USERS);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    String status = "in vacation";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_STATUS, status);
+
+    pushFactory
+        .create(
+            db,
+            foo.getIdent(),
+            allUsersRepo,
+            "Update account config",
+            AccountConfig.ACCOUNT_CONFIG,
+            ac.toText())
+        .to(userRef)
+        .assertOkStatus();
+    accountIndexedCounter.assertReindexOf(foo);
+
+    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    assertThat(info.email).isEqualTo(noEmail);
+    assertThat(info.name).isEqualTo(foo.fullName);
+    assertThat(info.status).isEqualTo(status);
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIfPreferredEmailDoesNotExistAsExtId() throws Exception {
+    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
+    String userRef = RefNames.refsUsers(foo.id);
+    accountIndexedCounter.clear();
+
+    InternalGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
+    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    String email = "some.email@example.com";
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setString(AccountConfig.ACCOUNT, null, AccountConfig.KEY_PREFERRED_EMAIL, email);
+
+    pushFactory
+        .create(
+            db,
+            foo.getIdent(),
+            allUsersRepo,
+            "Update account config",
+            AccountConfig.ACCOUNT_CONFIG,
+            ac.toText())
+        .to(userRef)
+        .assertOkStatus();
+    accountIndexedCounter.assertReindexOf(foo);
+
+    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    assertThat(info.email).isEqualTo(email);
+    assertThat(info.name).isEqualTo(foo.fullName);
+  }
+
+  @Test
+  public void pushAccountConfigToUserBranchIsRejectedIfOwnAccountIsDeactivated() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                allUsersRepo,
+                "Update account config",
+                AccountConfig.ACCOUNT_CONFIG,
+                ac.toText())
+            .to(RefNames.REFS_USERS_SELF);
+    r.assertErrorStatus("invalid account configuration");
+    r.assertMessage("cannot deactivate own account");
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  @Sandboxed
+  public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestAccount foo = accountCreator.create(name("foo"));
+    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
+    String userRef = RefNames.refsUsers(foo.id);
+    accountIndexedCounter.clear();
+
+    InternalGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
+    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, userRef + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config ac = getAccountConfig(allUsersRepo);
+    ac.setBoolean(AccountConfig.ACCOUNT, null, AccountConfig.KEY_ACTIVE, false);
+
+    pushFactory
+        .create(
+            db,
+            admin.getIdent(),
+            allUsersRepo,
+            "Update account config",
+            AccountConfig.ACCOUNT_CONFIG,
+            ac.toText())
+        .to(userRef)
+        .assertOkStatus();
+    accountIndexedCounter.assertReindexOf(foo);
+
+    assertThat(gApi.accounts().id(foo.id.get()).getActive()).isFalse();
+  }
+
+  @Test
+  @Sandboxed
+  public void cannotCreateUserBranch() throws Exception {
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+
+    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create user branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void createUserBranchWithAccessDatabaseCapability() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+
+    String userRef = RefNames.refsUsers(new Account.Id(seq.nextAccountId()));
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef).assertOkStatus();
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNotNull();
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void cannotCreateNonUserBranchUnderRefsUsersWithAccessDatabaseCapability()
+      throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.CREATE);
+    grant(allUsers, RefNames.REFS_USERS + "*", Permission.PUSH);
+
+    String userRef = RefNames.REFS_USERS + "foo";
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    PushOneCommit.Result r = pushFactory.create(db, admin.getIdent(), allUsersRepo).to(userRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create non-user branch under refs/users/.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void createDefaultUserBranch() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNull();
+    }
+
+    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.CREATE);
+    grant(allUsers, RefNames.REFS_USERS_DEFAULT, Permission.PUSH);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    pushFactory
+        .create(db, admin.getIdent(), allUsersRepo)
+        .to(RefNames.REFS_USERS_DEFAULT)
+        .assertOkStatus();
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(RefNames.REFS_USERS_DEFAULT)).isNotNull();
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void cannotDeleteUserBranch() throws Exception {
+    grant(
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        Permission.DELETE,
+        true,
+        REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    String userRef = RefNames.refsUsers(admin.id);
+    PushResult r = deleteRef(allUsersRepo, userRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(refUpdate.getMessage()).contains("Not allowed to delete user branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNotNull();
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    grant(
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        Permission.DELETE,
+        true,
+        REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    String userRef = RefNames.refsUsers(admin.id);
+    PushResult r = deleteRef(allUsersRepo, userRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+
+    assertThat(accountCache.getOrNull(admin.id)).isNull();
+    assertThat(accountQueryProvider.get().byDefault(admin.id.toString())).isEmpty();
+  }
+
+  @Test
   public void addGpgKey() throws Exception {
     TestKey key = validKeyWithoutExpiration();
     String id = key.getKeyIdString();
     addExternalIdEmail(admin, "test1@example.com");
 
+    sender.clear();
     assertKeyMapContains(key, addGpgKey(key.getPublicKeyArmored()));
     assertKeys(key);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new GPG keys have been added");
 
     setApiUser(user);
     exception.expect(ResourceNotFoundException.class);
@@ -689,27 +1625,46 @@
   }
 
   @Test
+  public void adminCannotAddGpgKeyToOtherAccount() throws Exception {
+    TestKey key = validKeyWithoutExpiration();
+    addExternalIdEmail(user, "test1@example.com");
+
+    sender.clear();
+    setApiUser(admin);
+    exception.expect(ResourceNotFoundException.class);
+    addGpgKey(user, key.getPublicKeyArmored());
+  }
+
+  @Test
   public void reAddExistingGpgKey() throws Exception {
     addExternalIdEmail(admin, "test5@example.com");
     TestKey key = validKeyWithSecondUserId();
     String id = key.getKeyIdString();
     PGPPublicKey pk = key.getPublicKey();
 
+    sender.clear();
     GpgKeyInfo info = addGpgKey(armor(pk)).get(id);
     assertThat(info.userIds).hasSize(2);
     assertIteratorSize(2, getOnlyKeyFromStore(key).getUserIDs());
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new GPG keys have been added");
 
     pk = PGPPublicKey.removeCertification(pk, "foo:myId");
+    sender.clear();
     info = addGpgKey(armor(pk)).get(id);
     assertThat(info.userIds).hasSize(1);
     assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
+    // TODO: Issue 10769: Adding an already existing key should not result in a notification email
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new GPG keys have been added");
   }
 
   @Test
   public void addOtherUsersGpgKey_Conflict() throws Exception {
     // Both users have a matching external ID for this key.
     addExternalIdEmail(admin, "test5@example.com");
-    externalIdsUpdate.insert(db, ExternalId.create("foo", "myId", user.getId()));
+    externalIdsUpdate.insert(ExternalId.create("foo", "myId", user.getId()));
+    accountIndexedCounter.assertReindexOf(user);
 
     TestKey key = validKeyWithSecondUserId();
     addGpgKey(key.getPublicKeyArmored());
@@ -717,7 +1672,7 @@
 
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("GPG key already associated with another account");
-    addGpgKey(key.getPublicKeyArmored());
+    addGpgKey(user, key.getPublicKeyArmored());
   }
 
   @Test
@@ -730,6 +1685,7 @@
     }
     gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.<String>of());
     assertKeys(keys);
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
@@ -740,8 +1696,12 @@
     addGpgKey(key.getPublicKeyArmored());
     assertKeys(key);
 
+    sender.clear();
     gApi.accounts().self().gpgKey(id).delete();
+    accountIndexedCounter.assertReindexOf(admin);
     assertKeys();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("GPG keys have been deleted");
 
     exception.expect(ResourceNotFoundException.class);
     exception.expectMessage(id);
@@ -765,6 +1725,7 @@
                 ImmutableList.of(key5.getKeyIdString()));
     assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
     assertKeys(key1, key2);
+    accountIndexedCounter.assertReindexOf(admin);
 
     infos =
         gApi.accounts()
@@ -776,6 +1737,7 @@
     assertKeyMapContains(key5, infos);
     assertThat(infos.get(key1.getKeyIdString()).key).isNull();
     assertKeys(key2, key5);
+    accountIndexedCounter.assertReindexOf(admin);
 
     exception.expect(BadRequestException.class);
     exception.expectMessage("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
@@ -804,35 +1766,109 @@
     assertThat(info).hasSize(1);
     assertSequenceNumbers(info);
     SshKeyInfo key = info.get(0);
-    String inital = AccountCreator.publicKey(admin.sshKey, admin.email);
-    assertThat(key.sshPublicKey).isEqualTo(inital);
+    String initial = AccountCreator.publicKey(admin.sshKey, admin.email);
+    assertThat(key.sshPublicKey).isEqualTo(initial);
+    accountIndexedCounter.assertNoReindex();
 
     // Add a new key
+    sender.clear();
     String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
     gApi.accounts().self().addSshKey(newKey);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
 
     // Add an existing key (the request succeeds, but the key isn't added again)
-    gApi.accounts().self().addSshKey(inital);
+    sender.clear();
+    gApi.accounts().self().addSshKey(initial);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertNoReindex();
+    // TODO: Issue 10769: Adding an already existing key should not result in a notification email
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
 
     // Add another new key
+    sender.clear();
     String newKey2 = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
     gApi.accounts().self().addSshKey(newKey2);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(3);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("new SSH keys have been added");
 
     // Delete second key
+    sender.clear();
     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);
+    accountIndexedCounter.assertReindexOf(admin);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("SSH keys have been deleted");
+  }
+
+  @Test
+  @UseSsh
+  public void adminCanAddOrRemoveSshKeyOnOtherAccount() throws Exception {
+    // The test account should initially have exactly one ssh key
+    List<SshKeyInfo> info = gApi.accounts().id(user.username).listSshKeys();
+    assertThat(info).hasSize(1);
+    assertSequenceNumbers(info);
+    SshKeyInfo key = info.get(0);
+    String initial = AccountCreator.publicKey(user.sshKey, user.email);
+    assertThat(key.sshPublicKey).isEqualTo(initial);
+    accountIndexedCounter.assertNoReindex();
+
+    // Add a new key
+    sender.clear();
+    String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), user.email);
+    gApi.accounts().id(user.username).addSshKey(newKey);
+    info = gApi.accounts().id(user.username).listSshKeys();
+    assertThat(info).hasSize(2);
+    assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(user);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.rcpt()).containsExactly(user.emailAddress);
+    assertThat(message.body()).contains("new SSH keys have been added");
+
+    // Delete key
+    sender.clear();
+    gApi.accounts().id(user.username).deleteSshKey(1);
+    info = gApi.accounts().id(user.username).listSshKeys();
+    assertThat(info).hasSize(1);
+    accountIndexedCounter.assertReindexOf(user);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    message = sender.getMessages().get(0);
+    assertThat(message.rcpt()).containsExactly(user.emailAddress);
+    assertThat(message.body()).contains("SSH keys have been deleted");
+  }
+
+  @Test
+  @UseSsh
+  public void userCannotAddSshKeyToOtherAccount() throws Exception {
+    String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.accounts().id(admin.username).addSshKey(newKey);
+  }
+
+  @Test
+  @UseSsh
+  public void userCannotDeleteSshKeyOfOtherAccount() throws Exception {
+    setApiUser(user);
+    exception.expect(ResourceNotFoundException.class);
+    gApi.accounts().id(admin.username).deleteSshKey(0);
   }
 
   // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
@@ -841,18 +1877,113 @@
     // admin can reindex any account
     setApiUser(admin);
     gApi.accounts().id(user.username).index();
+    accountIndexedCounter.assertReindexOf(user);
 
     // user can reindex own account
     setApiUser(user);
     gApi.accounts().self().index();
+    accountIndexedCounter.assertReindexOf(user);
 
     // user cannot reindex any account
     exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to index account");
+    exception.expectMessage("modify account not permitted");
     gApi.accounts().id(admin.username).index();
   }
 
   @Test
+  @Sandboxed
+  public void checkConsistency() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    // Create an account with a preferred email.
+    String username = name("foo");
+    String email = username + "@example.com";
+    TestAccount account = accountCreator.create(username, email, "Foo Bar");
+
+    ConsistencyCheckInput input = new ConsistencyCheckInput();
+    input.checkAccounts = new CheckAccountsInput();
+    ConsistencyCheckInfo checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountsResult.problems).isEmpty();
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+
+    // Delete the external ID for the preferred email. This makes the account inconsistent since it
+    // now doesn't have an external ID for its preferred email.
+    externalIdsUpdate.delete(ExternalId.createEmail(account.getId(), email));
+    expectedProblems.add(
+        new ConsistencyProblemInfo(
+            ConsistencyProblemInfo.Status.ERROR,
+            "Account '"
+                + account.getId().get()
+                + "' has no external ID for its preferred email '"
+                + email
+                + "'"));
+
+    checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountsResult.problems).hasSize(expectedProblems.size());
+    assertThat(checkInfo.checkAccountsResult.problems).containsExactlyElementsIn(expectedProblems);
+  }
+
+  @Test
+  public void internalQueryFindActiveAndInactiveAccounts() throws Exception {
+    String name = name("foo");
+    assertThat(accountQueryProvider.get().byDefault(name)).isEmpty();
+
+    TestAccount foo1 = accountCreator.create(name + "-1");
+    assertThat(gApi.accounts().id(foo1.username).getActive()).isTrue();
+
+    TestAccount foo2 = accountCreator.create(name + "-2");
+    gApi.accounts().id(foo2.username).setActive(false);
+    assertThat(gApi.accounts().id(foo2.username).getActive()).isFalse();
+
+    assertThat(accountQueryProvider.get().byDefault(name)).hasSize(2);
+  }
+
+  @Test
+  public void checkMetaId() throws Exception {
+    // metaId is set when account is loaded
+    assertThat(accounts.get(admin.getId()).getMetaId()).isEqualTo(getMetaId(admin.getId()));
+
+    // metaId is set when account is created
+    AccountsUpdate au = accountsUpdate.create();
+    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    Account account = au.insert(accountId, a -> {});
+    assertThat(account.getMetaId()).isEqualTo(getMetaId(accountId));
+
+    // metaId is set when account is updated
+    Account updatedAccount = au.update(accountId, a -> a.setFullName("foo"));
+    assertThat(account.getMetaId()).isNotEqualTo(updatedAccount.getMetaId());
+    assertThat(updatedAccount.getMetaId()).isEqualTo(getMetaId(accountId));
+
+    // metaId is set when account is replaced
+    Account newAccount = new Account(accountId, TimeUtil.nowTs());
+    au.replace(newAccount);
+    assertThat(updatedAccount.getMetaId()).isNotEqualTo(newAccount.getMetaId());
+    assertThat(newAccount.getMetaId()).isEqualTo(getMetaId(accountId));
+  }
+
+  private EmailInput newEmailInput(String email, boolean noConfirmation) {
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = noConfirmation;
+    return input;
+  }
+
+  private EmailInput newEmailInput(String email) {
+    return newEmailInput(email, true);
+  }
+
+  private String getMetaId(Account.Id accountId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader or = repo.newObjectReader()) {
+      Ref ref = repo.exactRef(RefNames.refsUsers(accountId));
+      return ref != null ? ref.getObjectId().name() : null;
+    }
+  }
+
+  @Test
   public void createUserWithValidUsername() throws Exception {
     ImmutableList<String> names =
         ImmutableList.of(
@@ -884,6 +2015,135 @@
     }
   }
 
+  @Test
+  public void groups() throws Exception {
+    assertGroups(
+        admin.username, ImmutableList.of("Anonymous Users", "Registered Users", "Administrators"));
+
+    // TODO: update when test user is fixed to be included in "Anonymous Users" and
+    //      "Registered Users" groups
+    assertGroups(user.username, ImmutableList.of());
+
+    String group = createGroup("group");
+    String newUser = createAccount("user1", group);
+    assertGroups(newUser, ImmutableList.of(group));
+  }
+
+  @Test
+  public void updateDisplayName() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = name("test");
+    input.email = "user@gerrit.com";
+    gApi.accounts().create(input);
+    AuthRequest who = AuthRequest.forEmail(input.email);
+    AuthResult authResult = accountManager.authenticate(who);
+    assertThat(authResult.isNew()).isFalse();
+    AccountInfo info = gApi.accounts().id(input.email).get();
+    assertThat(info.username).isEqualTo(input.username);
+    assertThat(info.email).isEqualTo(input.email);
+    assertThat(info.name).isEqualTo(input.username);
+    who.setDisplayName("Something Else");
+    AuthResult authResult2 = accountManager.authenticate(who);
+    assertThat(authResult2.isNew()).isFalse();
+    assertThat(authResult2.getAccountId()).isEqualTo(authResult.getAccountId());
+    info = gApi.accounts().id(input.email).get();
+    assertThat(info.username).isEqualTo(input.username);
+    assertThat(info.email).isEqualTo(input.email);
+    assertThat(info.name).isEqualTo("Something Else");
+  }
+
+  @Test
+  public void userCanGenerateNewHttpPassword() throws Exception {
+    sender.clear();
+    String newPassword = gApi.accounts().self().generateHttpPassword();
+    assertThat(newPassword).isNotNull();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
+  }
+
+  @Test
+  public void adminCanGenerateNewHttpPasswordForUser() throws Exception {
+    setApiUser(admin);
+    sender.clear();
+    String newPassword = gApi.accounts().id(user.username).generateHttpPassword();
+    assertThat(newPassword).isNotNull();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
+  }
+
+  @Test
+  public void userCannotGenerateNewHttpPasswordForOtherUser() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.accounts().id(admin.username).generateHttpPassword();
+  }
+
+  @Test
+  public void userCannotExplicitlySetHttpPassword() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.accounts().self().setHttpPassword("my-new-password");
+  }
+
+  @Test
+  public void userCannotExplicitlySetHttpPasswordForOtherUser() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.accounts().id(admin.username).setHttpPassword("my-new-password");
+  }
+
+  @Test
+  public void userCanRemoveHttpPassword() throws Exception {
+    setApiUser(user);
+    sender.clear();
+    assertThat(gApi.accounts().self().setHttpPassword(null)).isNull();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was deleted");
+  }
+
+  @Test
+  public void userCannotRemoveHttpPasswordForOtherUser() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.accounts().id(admin.username).setHttpPassword(null);
+  }
+
+  @Test
+  public void adminCanExplicitlySetHttpPasswordForUser() throws Exception {
+    setApiUser(admin);
+    String httpPassword = "new-password-for-user";
+    sender.clear();
+    assertThat(gApi.accounts().id(user.username).setHttpPassword(httpPassword))
+        .isEqualTo(httpPassword);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
+  }
+
+  @Test
+  public void adminCanRemoveHttpPasswordForUser() throws Exception {
+    setApiUser(admin);
+    sender.clear();
+    assertThat(gApi.accounts().id(user.username).setHttpPassword(null)).isNull();
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).body()).contains("HTTP password was deleted");
+  }
+
+  @Test
+  public void cannotGenerateHttpPasswordWhenUsernameIsNotSet() throws Exception {
+    setApiUser(admin);
+    int userId = accountCreator.create().id.get();
+    assertThat(gApi.accounts().id(userId).get().username).isNull();
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("username");
+    gApi.accounts().id(userId).generateHttpPassword();
+  }
+
+  private void assertGroups(String user, List<String> expected) throws Exception {
+    List<String> actual =
+        gApi.accounts().id(user).getGroups().stream().map(g -> g.name).collect(toList());
+    assertThat(actual).containsExactlyElementsIn(expected);
+  }
+
   private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
     int seq = 1;
     for (SshKeyInfo key : sshKeys) {
@@ -948,7 +2208,9 @@
     Iterable<String> expectedFps =
         expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
     Iterable<String> actualFps =
-        GpgKeys.getGpgExtIds(db, currAccountId).transform(e -> e.key().id());
+        externalIds.byAccount(currAccountId, SCHEME_GPGKEY).stream()
+            .map(e -> e.key().id())
+            .collect(toSet());
     assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
@@ -965,7 +2227,9 @@
         .isEqualTo(Fingerprint.toString(expected.getPublicKey().getFingerprint()));
     List<String> userIds = ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
     assertThat(actual.userIds).named(id).containsExactlyElementsIn(userIds);
-    assertThat(actual.key).named(id).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
+    String key = actual.key;
+    assertThat(key).named(id).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
+    assertThat(key).named(id).endsWith("-----END PGP PUBLIC KEY BLOCK-----\n");
     assertThat(actual.status).isEqualTo(GpgKeyInfo.Status.TRUSTED);
     assertThat(actual.problems).isEmpty();
   }
@@ -973,12 +2237,22 @@
   private void addExternalIdEmail(TestAccount account, String email) throws Exception {
     checkNotNull(email);
     externalIdsUpdate.insert(
-        db, ExternalId.createWithEmail(name("test"), email, account.getId(), email));
+        ExternalId.createWithEmail(name("test"), email, account.getId(), email));
+    accountIndexedCounter.assertReindexOf(account);
     setApiUser(account);
   }
 
   private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
-    return gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    return addGpgKey(admin, armored);
+  }
+
+  private Map<String, GpgKeyInfo> addGpgKey(TestAccount account, String armored) throws Exception {
+    Map<String, GpgKeyInfo> gpgKeys =
+        gApi.accounts()
+            .id(account.username)
+            .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username).get());
+    return gpgKeys;
   }
 
   private void assertUser(AccountInfo info, TestAccount account) throws Exception {
@@ -996,4 +2270,105 @@
     assertThat(accounts).hasSize(1);
     assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.getId());
   }
+
+  private Config getAccountConfig(TestRepository<?> allUsersRepo) throws Exception {
+    Config ac = new Config();
+    try (TreeWalk tw =
+        TreeWalk.forPath(
+            allUsersRepo.getRepository(),
+            AccountConfig.ACCOUNT_CONFIG,
+            getHead(allUsersRepo.getRepository()).getTree())) {
+      assertThat(tw).isNotNull();
+      ac.fromText(
+          new String(
+              allUsersRepo
+                  .getRevWalk()
+                  .getObjectReader()
+                  .open(tw.getObjectId(0), OBJ_BLOB)
+                  .getBytes(),
+              UTF_8));
+    }
+    return ac;
+  }
+
+  private static class AccountIndexedCounter implements AccountIndexedListener {
+    private final AtomicLongMap<Integer> countsByAccount = AtomicLongMap.create();
+
+    @Override
+    public void onAccountIndexed(int id) {
+      countsByAccount.incrementAndGet(id);
+    }
+
+    void clear() {
+      countsByAccount.clear();
+    }
+
+    long getCount(Account.Id accountId) {
+      return countsByAccount.get(accountId.get());
+    }
+
+    void assertReindexOf(TestAccount testAccount) {
+      assertReindexOf(testAccount, 1);
+    }
+
+    void assertReindexOf(AccountInfo accountInfo) {
+      assertReindexOf(new Account.Id(accountInfo._accountId), 1);
+    }
+
+    void assertReindexOf(TestAccount testAccount, int expectedCount) {
+      assertThat(getCount(testAccount.id)).isEqualTo(expectedCount);
+      assertThat(countsByAccount).hasSize(1);
+      clear();
+    }
+
+    void assertReindexOf(Account.Id accountId, int expectedCount) {
+      assertThat(getCount(accountId)).isEqualTo(expectedCount);
+      countsByAccount.remove(accountId.get());
+    }
+
+    void assertNoReindex() {
+      assertThat(countsByAccount).isEmpty();
+    }
+  }
+
+  private static class RefUpdateCounter implements GitReferenceUpdatedListener {
+    private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
+
+    static String projectRef(Project.NameKey project, String ref) {
+      return projectRef(project.get(), ref);
+    }
+
+    static String projectRef(String project, String ref) {
+      return project + ":" + ref;
+    }
+
+    @Override
+    public void onGitReferenceUpdated(Event event) {
+      countsByProjectRefs.incrementAndGet(projectRef(event.getProjectName(), event.getRefName()));
+    }
+
+    void clear() {
+      countsByProjectRefs.clear();
+    }
+
+    long getCount(String projectRef) {
+      return countsByProjectRefs.get(projectRef);
+    }
+
+    void assertRefUpdateFor(String... projectRefs) {
+      Map<String, Integer> expectedRefUpdateCounts = new HashMap<>();
+      for (String projectRef : projectRefs) {
+        expectedRefUpdateCounts.put(projectRef, 1);
+      }
+      assertRefUpdateFor(expectedRefUpdateCounts);
+    }
+
+    void assertRefUpdateFor(Map<String, Integer> expectedProjectRefUpdateCounts) {
+      for (Map.Entry<String, Integer> e : expectedProjectRefUpdateCounts.entrySet()) {
+        assertThat(getCount(e.getKey())).isEqualTo(e.getValue());
+      }
+      assertThat(countsByProjectRefs).hasSize(expectedProjectRefUpdateCounts.size());
+      clear();
+    }
+  }
 }
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
index fbeeafd..8bf46d6 100644
--- 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
@@ -31,6 +31,7 @@
 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.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.inject.Inject;
@@ -52,7 +53,7 @@
   @Before
   public void setUp() throws Exception {
     String name = name("user42");
-    user42 = accounts.create(name, name + "@example.com", "User 42");
+    user42 = accountCreator.create(name, name + "@example.com", "User 42");
   }
 
   @After
@@ -66,14 +67,21 @@
         assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
       }
     }
-    accountCache.evictAll();
+    accountCache.evictAllNoReindex();
   }
 
   @Test
   public void getAndSetPreferences() throws Exception {
     GeneralPreferencesInfo o = gApi.accounts().id(user42.id.toString()).getPreferences();
     assertPrefs(o, GeneralPreferencesInfo.defaults(), "my", "changeTable");
-    assertThat(o.my).hasSize(7);
+    assertThat(o.my)
+        .containsExactly(
+            new MenuItem("Changes", "#/dashboard/self", null),
+            new MenuItem("Draft Comments", "#/q/has:draft", null),
+            new MenuItem("Edits", "#/q/has:edit", null),
+            new MenuItem("Watched Changes", "#/q/is:watched+is:open", null),
+            new MenuItem("Starred Changes", "#/q/is:starred", null),
+            new MenuItem("Groups", "#/groups/self", null));
     assertThat(o.changeTable).isEmpty();
 
     GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
@@ -106,8 +114,8 @@
 
     o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
     assertPrefs(o, i, "my");
-    assertThat(o.my).hasSize(1);
-    assertThat(o.changeTable).hasSize(1);
+    assertThat(o.my).containsExactlyElementsIn(i.my);
+    assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
   }
 
   @Test
@@ -161,4 +169,36 @@
     assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
   }
+
+  @Test
+  public void rejectMyMenuWithoutName() throws Exception {
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+    i.my = new ArrayList<>();
+    i.my.add(new MenuItem(null, "url"));
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("name for menu item is required");
+    gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+  }
+
+  @Test
+  public void rejectMyMenuWithoutUrl() throws Exception {
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+    i.my = new ArrayList<>();
+    i.my.add(new MenuItem("name", null));
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("URL for menu item is required");
+    gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+  }
+
+  @Test
+  public void trimMyMenuInput() throws Exception {
+    GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
+    i.my = new ArrayList<>();
+    i.my.add(new MenuItem(" name\t", " url\t", " _blank\t", " id\t"));
+
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.getId().toString()).setPreferences(i);
+    assertThat(o.my).containsExactly(new MenuItem("name", "url", "_blank", "id"));
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/AbandonIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/AbandonIT.java
new file mode 100644
index 0000000..2c1a5b3
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -0,0 +1,183 @@
+// 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.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.client.ChangeStatus;
+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.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.AbandonUtil;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class AbandonIT extends AbstractDaemonTest {
+  @Inject private AbandonUtil abandonUtil;
+
+  @Test
+  public void abandon() 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);
+    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 batchAbandon() throws Exception {
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a = createChange();
+    PushOneCommit.Result b = createChange();
+    List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
+    changeAbandoner.batchAbandon(
+        batchUpdateFactory, a.getChange().project(), user, list, "deadbeef");
+
+    ChangeInfo info = get(a.getChangeId());
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+
+    info = get(b.getChangeId());
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+  }
+
+  @Test
+  public void batchAbandonChangeProject() throws Exception {
+    String project1Name = name("Project1");
+    String project2Name = name("Project2");
+    gApi.projects().create(project1Name);
+    gApi.projects().create(project2Name);
+    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
+
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
+    PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
+    List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
+    changeAbandoner.batchAbandon(batchUpdateFactory, new Project.NameKey(project1Name), user, list);
+  }
+
+  @Test
+  @GerritConfig(name = "changeCleanup.abandonAfter", value = "1w")
+  public void abandonInactiveOpenChanges() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+
+    // create 2 changes which will be abandoned ...
+    int id1 = createChange().getChange().getId().get();
+    int id2 = createChange().getChange().getId().get();
+
+    // ... because they are older than 1 week
+    TestTimeUtil.incrementClock(7 * 24, HOURS);
+
+    // create 1 new change that will not be abandoned
+    ChangeData cd = createChange().getChange();
+    int id3 = cd.getId().get();
+
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3);
+    assertThat(query("is:abandoned")).isEmpty();
+
+    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id3);
+    assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id1, id2);
+  }
+
+  @Test
+  public void abandonNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("abandon not permitted");
+    gApi.changes().id(changeId).abandon();
+  }
+
+  @Test
+  public void abandonAndRestoreAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(changeId).abandon();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+    gApi.changes().id(changeId).restore();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void restore() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+
+    gApi.changes().id(changeId).restore();
+    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
+  public void restoreNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    setApiUser(user);
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+    exception.expect(AuthException.class);
+    exception.expectMessage("restore not permitted");
+    gApi.changes().id(changeId).restore();
+  }
+
+  private List<Integer> toChangeNumbers(List<ChangeInfo> changes) {
+    return changes.stream().map(i -> i._number).collect(toList());
+  }
+}
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 e2d7715..94a82d8 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
@@ -19,18 +19,38 @@
 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_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
+import static com.google.gerrit.extensions.client.ListChangesOption.COMMIT_FOOTERS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
+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.TRACKING_IDS;
 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 com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
+import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 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.PROJECT_OWNERS;
 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 java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 import static org.junit.Assert.fail;
@@ -40,20 +60,22 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelFunction;
 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.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -62,15 +84,18 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.Comment.Range;
-import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -79,12 +104,16 @@
 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.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -103,13 +132,15 @@
 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.CurrentUser;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PostReview;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -117,18 +148,20 @@
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Constants;
@@ -145,10 +178,13 @@
 public class ChangeIT extends AbstractDaemonTest {
   private String systemTimeZone;
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+
+  private ChangeIndexedCounter changeIndexedCounter;
+  private RegistrationHandle changeIndexedCounterHandle;
+
   @Before
   public void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
@@ -160,6 +196,19 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
+  @Before
+  public void addChangeIndexedCounter() {
+    changeIndexedCounter = new ChangeIndexedCounter();
+    changeIndexedCounterHandle = changeIndexedListeners.add(changeIndexedCounter);
+  }
+
+  @After
+  public void removeChangeIndexedCounter() {
+    if (changeIndexedCounterHandle != null) {
+      changeIndexedCounterHandle.remove();
+    }
+  }
+
   @Test
   public void get() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -184,6 +233,475 @@
   }
 
   @Test
+  public void setPrivateByOwner() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    gApi.changes().id(changeId).setPrivate(true, null);
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
+
+    gApi.changes().id(changeId).setPrivate(false, null);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+
+    String msg = "This is a security fix that must not be public.";
+    gApi.changes().id(changeId).setPrivate(true, msg);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private\n\n" + msg);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
+
+    msg = "After this security fix has been released we can make it public now.";
+    gApi.changes().id(changeId).setPrivate(false, msg);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private\n\n" + msg);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+  }
+
+  @Test
+  public void administratorCanSetUserChangePrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    gApi.changes().id(changeId).setPrivate(true, null);
+    setApiUser(user);
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+  }
+
+  @Test
+  public void cannotSetOtherUsersChangePrivate() throws Exception {
+    PushOneCommit.Result result = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to mark private");
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+  }
+
+  @Test
+  public void accessPrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+    // Owner can always access its private changes.
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Add admin as a reviewer.
+    gApi.changes().id(result.getChangeId()).addReviewer(admin.getId().toString());
+
+    // This change should be visible for admin as a reviewer.
+    setApiUser(admin);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Remove admin from reviewers.
+    gApi.changes().id(result.getChangeId()).reviewer(admin.getId().toString()).remove();
+
+    // This change should not be visible for admin anymore.
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + result.getChangeId());
+    gApi.changes().id(result.getChangeId());
+  }
+
+  @Test
+  public void privateChangeOfOtherUserCanBeAccessedWithPermission() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+
+    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
+    setApiUser(user);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+  }
+
+  @Test
+  public void administratorCanUnmarkPrivateAfterMerging() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).setPrivate(true, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+    merge(result);
+    gApi.changes().id(changeId).setPrivate(false, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void administratorCanMarkPrivateAfterMerging() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+    merge(result);
+    gApi.changes().id(changeId).setPrivate(true, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+  }
+
+  @Test
+  public void ownerCannotMarkPrivateAfterMerging() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    merge(result);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to mark private");
+    gApi.changes().id(changeId).setPrivate(true, null);
+  }
+
+  @Test
+  public void ownerCanUnmarkPrivateAfterMerging() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+    gApi.changes().id(changeId).addReviewer(admin.getId().toString());
+    gApi.changes().id(changeId).setPrivate(true, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+
+    merge(result);
+
+    setApiUser(user);
+    gApi.changes().id(changeId).setPrivate(false, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void setWorkInProgressNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result rwip = createChange();
+    String changeId = rwip.getChangeId();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to toggle work in progress");
+    gApi.changes().id(changeId).setWorkInProgress();
+  }
+
+  @Test
+  public void setWorkInProgressAllowedAsAdmin() throws Exception {
+    setApiUser(user);
+    String changeId =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+
+    setApiUser(admin);
+    gApi.changes().id(changeId).setWorkInProgress();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void setWorkInProgressAllowedAsProjectOwner() throws Exception {
+    setApiUser(user);
+    String changeId =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+
+    com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
+    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    setApiUser(user2);
+    gApi.changes().id(changeId).setWorkInProgress();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void createWipChangeWithWorkInProgressByDefaultForProject() throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.workInProgressByDefault = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(input);
+    String changeId =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void setReadyForReviewNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result rready = createChange();
+    String changeId = rready.getChangeId();
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to toggle work in progress");
+    gApi.changes().id(changeId).setReadyForReview();
+  }
+
+  @Test
+  public void setReadyForReviewAllowedAsAdmin() throws Exception {
+    setApiUser(user);
+    String changeId =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    setApiUser(admin);
+    gApi.changes().id(changeId).setReadyForReview();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void setReadyForReviewAllowedAsProjectOwner() throws Exception {
+    setApiUser(user);
+    String changeId =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
+    grant(project, "refs/*", Permission.OWNER, false, REGISTERED_USERS);
+    setApiUser(user2);
+    gApi.changes().id(changeId).setReadyForReview();
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void hasReviewStarted() throws Exception {
+    PushOneCommit.Result r = createWorkInProgressChange();
+    String changeId = r.getChangeId();
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.hasReviewStarted).isFalse();
+
+    gApi.changes().id(changeId).setReadyForReview();
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.hasReviewStarted).isTrue();
+  }
+
+  @Test
+  public void pendingReviewersInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result r = createWorkInProgressChange();
+    String changeId = r.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().pendingReviewers).isEmpty();
+
+    // Add some pending reviewers.
+    TestAccount user1 =
+        accountCreator.create(name("user1"), name("user1") + "@example.com", "User 1");
+    TestAccount user2 =
+        accountCreator.create(name("user2"), name("user2") + "@example.com", "User 2");
+    TestAccount user3 =
+        accountCreator.create(name("user3"), name("user3") + "@example.com", "User 3");
+    TestAccount user4 =
+        accountCreator.create(name("user4"), name("user4") + "@example.com", "User 4");
+    ReviewInput in =
+        ReviewInput.noScore()
+            .reviewer(user1.email)
+            .reviewer(user2.email)
+            .reviewer(user3.email, CC, false)
+            .reviewer(user4.email, CC, false)
+            .reviewer("byemail1@example.com")
+            .reviewer("byemail2@example.com")
+            .reviewer("byemail3@example.com", CC, false)
+            .reviewer("byemail4@example.com", CC, false);
+    ReviewResult result = gApi.changes().id(changeId).revision("current").review(in);
+    assertThat(result.reviewers).isNotEmpty();
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    Function<Collection<AccountInfo>, Collection<String>> toEmails =
+        ais -> ais.stream().map(ai -> ai.email).collect(toSet());
+    assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
+        .containsExactly(
+            admin.email, user1.email, user2.email, "byemail1@example.com", "byemail2@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
+        .containsExactly(user3.email, user4.email, "byemail3@example.com", "byemail4@example.com");
+    assertThat(info.pendingReviewers.get(REMOVED)).isNull();
+
+    // Stage some pending reviewer removals.
+    gApi.changes().id(changeId).reviewer(user1.email).remove();
+    gApi.changes().id(changeId).reviewer(user3.email).remove();
+    gApi.changes().id(changeId).reviewer("byemail1@example.com").remove();
+    gApi.changes().id(changeId).reviewer("byemail3@example.com").remove();
+    info = gApi.changes().id(changeId).get();
+    assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
+        .containsExactly(admin.email, user2.email, "byemail2@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
+        .containsExactly(user4.email, "byemail4@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
+        .containsExactly(user1.email, user3.email, "byemail1@example.com", "byemail3@example.com");
+
+    // "Undo" a removal.
+    in = ReviewInput.noScore().reviewer(user1.email);
+    gApi.changes().id(changeId).revision("current").review(in);
+    info = gApi.changes().id(changeId).get();
+    assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
+        .containsExactly(admin.email, user1.email, user2.email, "byemail2@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
+        .containsExactly(user4.email, "byemail4@example.com");
+    assertThat(toEmails.apply(info.pendingReviewers.get(REMOVED)))
+        .containsExactly(user3.email, "byemail1@example.com", "byemail3@example.com");
+
+    // "Commit" by moving out of WIP.
+    gApi.changes().id(changeId).setReadyForReview();
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.pendingReviewers).isEmpty();
+    assertThat(toEmails.apply(info.reviewers.get(REVIEWER)))
+        .containsExactly(admin.email, user1.email, user2.email, "byemail2@example.com");
+    assertThat(toEmails.apply(info.reviewers.get(CC)))
+        .containsExactly(user4.email, "byemail4@example.com");
+    assertThat(info.reviewers.get(REMOVED)).isNull();
+  }
+
+  @Test
+  public void toggleWorkInProgressState() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // With message
+    gApi.changes().id(changeId).setWorkInProgress("Needs some refactoring");
+
+    ChangeInfo info = gApi.changes().id(changeId).get();
+
+    assertThat(info.workInProgress).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).contains("Needs some refactoring");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
+
+    gApi.changes().id(changeId).setReadyForReview("PTAL");
+
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.workInProgress).isNull();
+    assertThat(Iterables.getLast(info.messages).message).contains("PTAL");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
+
+    // No message
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    info = gApi.changes().id(changeId).get();
+
+    assertThat(info.workInProgress).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Work In Progress");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
+
+    gApi.changes().id(changeId).setReadyForReview();
+
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.workInProgress).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Ready For Review");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
+  }
+
+  @Test
+  public void reviewAndStartReview() throws Exception {
+    PushOneCommit.Result r = createWorkInProgressChange();
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(false);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    assertThat(result.ready).isTrue();
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isNull();
+  }
+
+  @Test
+  public void reviewAndMoveToWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    assertThat(result.ready).isNull();
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
+  public void reviewAndSetWorkInProgressAndAddReviewerAndVote() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    ReviewInput in =
+        ReviewInput.approve().reviewer(user.email).label("Code-Review", 1).setWorkInProgress(true);
+    gApi.changes().id(r.getChangeId()).revision("current").review(in);
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isTrue();
+    assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(admin.id.get(), user.id.get());
+    assertThat(info.labels.get("Code-Review").recommended._accountId).isEqualTo(admin.id.get());
+  }
+
+  @Test
+  public void reviewWithWorkInProgressAndReadyReturnsError() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = ReviewInput.noScore();
+    in.ready = true;
+    in.workInProgress = true;
+    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    assertThat(result.error).isEqualTo(PostReview.ERROR_WIP_READY_MUTUALLY_EXCLUSIVE);
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void reviewWithWorkInProgressChangeOwner() throws Exception {
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id);
+
+    setApiUser(user);
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    gApi.changes().id(r.getChangeId()).current().review(in);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
+  @TestProjectInput(cloneAs = "user")
+  public void reviewWithWithWorkInProgressAdmin() throws Exception {
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id);
+
+    setApiUser(admin);
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    gApi.changes().id(r.getChangeId()).current().review(in);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
+  public void reviewWithWorkInProgressByNonOwnerReturnsError() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to toggle work in progress");
+    gApi.changes().id(r.getChangeId()).current().review(in);
+  }
+
+  @Test
+  public void reviewWithReadyByNonOwnerReturnsError() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = ReviewInput.noScore().setReady(true);
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to toggle work in progress");
+    gApi.changes().id(r.getChangeId()).current().review(in);
+  }
+
+  @Test
   public void getAmbiguous() throws Exception {
     PushOneCommit.Result r1 = createChange();
     String changeId = r1.getChangeId();
@@ -211,96 +729,6 @@
   }
 
   @Test
-  public void abandon() 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);
-    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 batchAbandon() throws Exception {
-    CurrentUser user = atrScope.get().getUser();
-    PushOneCommit.Result a = createChange();
-    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
-    assertThat(controlA).hasSize(1);
-    PushOneCommit.Result b = createChange();
-    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
-    assertThat(controlB).hasSize(1);
-    List<ChangeControl> list = ImmutableList.of(controlA.get(0), controlB.get(0));
-    changeAbandoner.batchAbandon(controlA.get(0).getProject().getNameKey(), user, list, "deadbeef");
-
-    ChangeInfo info = get(a.getChangeId());
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
-
-    info = get(b.getChangeId());
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
-  }
-
-  @Test
-  public void batchAbandonChangeProject() throws Exception {
-    String project1Name = name("Project1");
-    String project2Name = name("Project2");
-    gApi.projects().create(project1Name);
-    gApi.projects().create(project2Name);
-    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
-    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
-
-    CurrentUser user = atrScope.get().getUser();
-    PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
-    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
-    assertThat(controlA).hasSize(1);
-    PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
-    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
-    assertThat(controlB).hasSize(1);
-    List<ChangeControl> list = ImmutableList.of(controlA.get(0), controlB.get(0));
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
-    changeAbandoner.batchAbandon(new Project.NameKey(project1Name), user, list);
-  }
-
-  @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();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
-
-    gApi.changes().id(changeId).restore();
-    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
   public void revert() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -321,6 +749,57 @@
 
     assertThat(revertChange.messages).hasSize(1);
     assertThat(revertChange.messages.iterator().next().message).isEqualTo("Uploaded patch set 1.");
+    assertThat(revertChange.revertOf).isEqualTo(gApi.changes().id(r.getChangeId()).get()._number);
+  }
+
+  @Test
+  public void revertNotifications() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    sender.clear();
+    ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(2);
+    assertThat(sender.getMessages(revertChange.changeId, "newchange")).hasSize(1);
+    assertThat(sender.getMessages(r.getChangeId(), "revert")).hasSize(1);
+  }
+
+  @Test
+  public void revertPreservesReviewersAndCcs() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = ReviewInput.approve();
+    in.reviewer(user.email);
+    in.reviewer(accountCreator.user2().email, ReviewerState.CC, true);
+    // Add user as reviewer that will create the revert
+    in.reviewer(accountCreator.admin2().email);
+
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    // expect both the original reviewers and CCs to be preserved
+    // original owner should be added as reviewer, user requesting the revert (new owner) removed
+    setApiUser(accountCreator.admin2());
+    Map<ReviewerState, Collection<AccountInfo>> result =
+        gApi.changes().id(r.getChangeId()).revert().get().reviewers;
+    assertThat(result).containsKey(ReviewerState.REVIEWER);
+
+    List<Integer> reviewers =
+        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    if (notesMigration.readChanges()) {
+      assertThat(result).containsKey(ReviewerState.CC);
+      List<Integer> ccs =
+          result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+      assertThat(ccs).containsExactly(accountCreator.user2().id.get());
+      assertThat(reviewers).containsExactly(user.id.get(), admin.id.get());
+    } else {
+      assertThat(reviewers)
+          .containsExactly(user.id.get(), admin.id.get(), accountCreator.user2().id.get());
+    }
   }
 
   @Test
@@ -361,25 +840,42 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
+    // Add an approval whose score should be copied on trivial rebase
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
     String changeId = r2.getChangeId();
     // Rebase the second change
     rebase.call(changeId);
 
-    // Second change should have 2 patch sets
-    ChangeInfo c2 = gApi.changes().id(changeId).get();
+    // Second change should have 2 patch sets and an approval
+    ChangeInfo c2 = gApi.changes().id(changeId).get(CURRENT_REVISION, DETAILED_LABELS);
     assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
 
     // ...and the committer and description should be correct
-    ChangeInfo info =
-        gApi.changes()
-            .id(changeId)
-            .get(EnumSet.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT));
+    ChangeInfo info = gApi.changes().id(changeId).get(CURRENT_REVISION, CURRENT_COMMIT);
     GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
     assertThat(committer.name).isEqualTo(admin.fullName);
     assertThat(committer.email).isEqualTo(admin.email);
     String description = info.revisions.get(info.currentRevision).description;
     assertThat(description).isEqualTo("Rebase");
 
+    // ...and the approval was copied
+    LabelInfo cr = c2.labels.get("Code-Review");
+    assertThat(cr).isNotNull();
+    assertThat(cr.all).hasSize(1);
+    assertThat(cr.all.get(0).value).isEqualTo(1);
+
+    if (notesMigration.changePrimaryStorage() == PrimaryStorage.REVIEW_DB) {
+      // Ensure record was actually copied under ReviewDb
+      List<PatchSetApproval> psas =
+          unwrapDb(db)
+              .patchSetApprovals()
+              .byPatchSet(new PatchSet.Id(new Change.Id(c2._number), 2))
+              .toList();
+      assertThat(psas).hasSize(1);
+      assertThat(psas.get(0).getValue()).isEqualTo((short) 1);
+    }
+
     // Rebasing the second change again should fail
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("Change is already up to date");
@@ -387,6 +883,16 @@
   }
 
   @Test
+  public void rebaseOnNonExistingChange() throws Exception {
+    String changeId = createChange().getChangeId();
+    RebaseInput in = new RebaseInput();
+    in.base = "999999";
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Base change not found: " + in.base);
+    gApi.changes().id(changeId).rebase(in);
+  }
+
+  @Test
   public void rebaseNotAllowedWithoutPermission() throws Exception {
     // Create two changes both with the same parent
     PushOneCommit.Result r = createChange();
@@ -418,7 +924,7 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    grant(Permission.REBASE, project, "refs/heads/master", false, REGISTERED_USERS);
+    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
 
     // Rebase the second
     String changeId = r2.getChangeId();
@@ -427,23 +933,47 @@
   }
 
   @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);
+  public void rebaseNotAllowedWithoutPushPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
+    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("rebase not permitted");
+    gApi.changes().id(changeId).rebase();
   }
 
   @Test
-  public void deleteDraftChange() throws Exception {
-    PushOneCommit.Result r = createChange("refs/drafts/master");
-    String changeId = r.getChangeId();
-    assertThat(query(changeId)).hasSize(1);
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.DRAFT);
-    gApi.changes().id(changeId).delete();
-    assertThat(query(changeId)).isEmpty();
+  public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
 
-    eventRecorder.assertChangeDeletedEvents(changeId, admin.email);
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    exception.expect(AuthException.class);
+    exception.expectMessage("rebase not permitted");
+    gApi.changes().id(changeId).rebase();
   }
 
   @Test
@@ -457,17 +987,16 @@
     PushOneCommit.Result changeResult =
         pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
   @Test
   public void deleteNewChangeAsUserWithDeleteChangesPermissionForGroup() throws Exception {
-    allow(Permission.DELETE_CHANGES, REGISTERED_USERS, "refs/*");
+    allow("refs/*", Permission.DELETE_CHANGES, REGISTERED_USERS);
     deleteChangeAsUser(admin, user);
   }
 
@@ -494,13 +1023,13 @@
 
   @Test
   public void deleteChangeAsUserWithDeleteOwnChangesPermissionForGroup() throws Exception {
-    allow(Permission.DELETE_OWN_CHANGES, REGISTERED_USERS, "refs/*");
+    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
     deleteChangeAsUser(user, user);
   }
 
   @Test
   public void deleteChangeAsUserWithDeleteOwnChangesPermissionForOwners() throws Exception {
-    allow(Permission.DELETE_OWN_CHANGES, CHANGE_OWNER, "refs/*");
+    allow("refs/*", Permission.DELETE_OWN_CHANGES, CHANGE_OWNER);
     deleteChangeAsUser(user, user);
   }
 
@@ -516,7 +1045,10 @@
       in.project = projectName.get();
       in.branch = "refs/heads/master";
       in.subject = "test";
-      String changeId = gApi.changes().create(in).get().changeId;
+      ChangeInfo changeInfo = gApi.changes().create(in).get();
+      String changeId = changeInfo.changeId;
+      int id = changeInfo._number;
+      String commit = changeInfo.currentRevision;
 
       assertThat(gApi.changes().id(changeId).info().owner._accountId).isEqualTo(owner.id.get());
 
@@ -525,10 +1057,12 @@
 
       assertThat(query(changeId)).isEmpty();
 
+      String ref = new Change.Id(id).toRefPrefix() + "1";
+      eventRecorder.assertRefUpdatedEvents(projectName.get(), ref, null, commit, commit, null);
       eventRecorder.assertChangeDeletedEvents(changeId, deleteAs.email);
     } finally {
-      removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
-      removePermission(Permission.DELETE_CHANGES, project, "refs/*");
+      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
+      removePermission(project, "refs/*", Permission.DELETE_CHANGES);
     }
   }
 
@@ -539,19 +1073,18 @@
 
   @Test
   public void deleteNewChangeOfAnotherUserWithDeleteOwnChangesPermission() throws Exception {
-    allow(Permission.DELETE_OWN_CHANGES, REGISTERED_USERS, "refs/*");
+    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
 
     try {
       PushOneCommit.Result changeResult = createChange();
       String changeId = changeResult.getChangeId();
-      Change.Id id = changeResult.getChange().getId();
 
       setApiUser(user);
       exception.expect(AuthException.class);
-      exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+      exception.expectMessage("delete not permitted");
       gApi.changes().id(changeId).delete();
     } finally {
-      removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
+      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
     }
   }
 
@@ -572,13 +1105,12 @@
     PushOneCommit.Result changeResult =
         pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     setApiUser(user);
     gApi.changes().id(changeId).abandon();
 
     exception.expect(AuthException.class);
-    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
@@ -600,34 +1132,32 @@
   public void deleteMergedChange() throws Exception {
     PushOneCommit.Result changeResult = createChange();
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     merge(changeResult);
 
     exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage(String.format("Deleting merged change %s is not allowed", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
   @Test
   @TestProjectInput(cloneAs = "user")
   public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
-    allow(Permission.DELETE_OWN_CHANGES, REGISTERED_USERS, "refs/*");
+    allow("refs/*", Permission.DELETE_OWN_CHANGES, REGISTERED_USERS);
 
     try {
       PushOneCommit.Result changeResult =
           pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
       String changeId = changeResult.getChangeId();
-      Change.Id id = changeResult.getChange().getId();
 
       merge(changeResult);
 
       setApiUser(user);
       exception.expect(MethodNotAllowedException.class);
-      exception.expectMessage(String.format("Deleting merged change %s is not allowed", id));
+      exception.expectMessage("delete not permitted");
       gApi.changes().id(changeId).delete();
     } finally {
-      removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
+      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
     }
   }
 
@@ -853,7 +1383,7 @@
     Util.allow(
         cfg,
         Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID(),
+        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID(),
         "refs/*");
     Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     saveProjectConfig(p, cfg);
@@ -930,7 +1460,7 @@
     Util.allow(
         cfg,
         Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID(),
+        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID(),
         "refs/*");
     Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     saveProjectConfig(p, cfg);
@@ -977,7 +1507,7 @@
     Util.allow(
         cfg,
         Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID(),
+        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID(),
         "refs/*");
     Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     saveProjectConfig(p, cfg);
@@ -1001,23 +1531,52 @@
     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);
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(user.email);
+    assertThat(r.error).contains("does not have permission to see this change");
+    assertThat(r.reviewers).isNull();
   }
 
   @Test
   public void addReviewerThatIsInactive() throws Exception {
-    PushOneCommit.Result r = createChange();
+    PushOneCommit.Result result = createChange();
 
     String username = name("new-user");
     gApi.accounts().create(username).setActive(false);
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = username;
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account of " + username + " is inactive.");
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(username);
+    assertThat(r.error).contains("identifies an inactive account");
+    assertThat(r.reviewers).isNull();
+  }
+
+  @Test
+  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result result = createChange();
+
+    String username = "user@domain.com";
+    gApi.accounts().create(username).setActive(false);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = username;
+    in.state = ReviewerState.CC;
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(username);
+    assertThat(r.error).isNull();
+    // When adding by email, the reviewers field is also empty because we can't
+    // render a ReviewerInfo object for a non-account.
+    assertThat(r.reviewers).isNull();
   }
 
   @Test
@@ -1055,6 +1614,63 @@
     rsrc = parseResource(r);
     assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
     assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
+
+    // Change status of reviewer and ensure ETag is updated.
+    oldETag = rsrc.getETag();
+    gApi.accounts().id(user.id.get()).setStatus("new status");
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+  }
+
+  @Test
+  public void listReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers()).hasSize(1);
+
+    TestAccount user1 =
+        accountCreator.create(name("user1"), name("user1") + "@example.com", "User 1");
+    in.reviewer = user1.email;
+    in.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a.username))
+        .containsExactly(user.username, user1.username);
+  }
+
+  @Test
+  public void notificationsForAddedWorkInProgressReviewers() throws Exception {
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    ReviewInput batchIn = new ReviewInput();
+    batchIn.reviewers = ImmutableList.of(in);
+
+    // Added reviewers not notified by default.
+    PushOneCommit.Result r = createWorkInProgressChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(sender.getMessages()).hasSize(0);
+
+    // Default notification handling can be overridden.
+    r = createWorkInProgressChange();
+    in.notify = NotifyHandling.OWNER_REVIEWERS;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(sender.getMessages()).hasSize(1);
+    sender.clear();
+
+    // Reviewers added via PostReview also not notified by default.
+    // In this case, the child ReviewerInput has a notify=OWNER_REVIEWERS
+    // that should be ignored.
+    r = createWorkInProgressChange();
+    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
+    assertThat(sender.getMessages()).hasSize(0);
+
+    // Top-level notify property can force notifications when adding reviewer
+    // via PostReview.
+    r = createWorkInProgressChange();
+    batchIn.notify = NotifyHandling.OWNER_REVIEWERS;
+    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
+    assertThat(sender.getMessages()).hasSize(1);
   }
 
   @Test
@@ -1066,7 +1682,7 @@
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "ab" with one user: testUser
-    TestAccount testUser = accounts.create("abcd", "abcd@test.com", "abcd");
+    TestAccount testUser = accountCreator.create("abcd", "abcd@test.com", "abcd");
     String testGroup = createGroupWithRealName("ab");
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
@@ -1110,8 +1726,8 @@
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "kobe" with one user: lee
-    TestAccount testUser = accounts.create("kobebryant", "kobebryant@test.com", "kobebryant");
-    TestAccount myGroupUser = accounts.create("lee", "lee@test.com", "lee");
+    TestAccount testUser = accountCreator.create("kobebryant", "kobebryant@test.com", "kobebryant");
+    TestAccount myGroupUser = accountCreator.create("lee", "lee@test.com", "lee");
 
     String testGroup = createGroupWithRealName("kobe");
     GroupApi groupApi = gApi.groups().id(testGroup);
@@ -1152,7 +1768,7 @@
 
   @Test
   public void addReviewerWithNoteDbWhenDummyApprovalInReviewDbExists() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
     assume().that(notesMigration.changePrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
 
     PushOneCommit.Result r = createChange();
@@ -1230,7 +1846,6 @@
     // Exact request format made by GWT UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
     ReviewInput in = new ReviewInput();
     in.labels = ImmutableMap.of("Code-Review", (short) 0);
-    in.strictLabels = true;
     in.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
     in.message = "comment";
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
@@ -1297,6 +1912,17 @@
   }
 
   @Test
+  public void eTagChangesWhenOwnerUpdatesAccountStatus() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeResource rsrc = parseResource(r);
+    String oldETag = rsrc.getETag();
+
+    gApi.accounts().id(admin.id.get()).setStatus("new status");
+    rsrc = parseResource(r);
+    assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
+  }
+
+  @Test
   public void emailNotificationForFileLevelComment() throws Exception {
     String changeId = createChange().getChangeId();
 
@@ -1466,7 +2092,59 @@
 
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("delete reviewer not permitted");
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).remove();
+  }
+
+  @Test
+  public void removeReviewerSelfFromMergedChangeNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    setApiUser(user);
+    recommend(changeId);
+
+    setApiUser(admin);
+    approve(changeId);
+    gApi.changes().id(changeId).revision(r.getCommit().name()).submit();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer("self").remove();
+  }
+
+  @Test
+  public void removeReviewerSelfFromAbandonedChangePermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    setApiUser(user);
+    recommend(changeId);
+
+    setApiUser(admin);
+    gApi.changes().id(changeId).abandon();
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).reviewer("self").remove();
+    eventRecorder.assertReviewerDeletedEvents(changeId, user.email);
+  }
+
+  @Test
+  public void removeOtherReviewerFromAbandonedChangeNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    setApiUser(user);
+    recommend(changeId);
+
+    setApiUser(admin);
+    approve(changeId);
+    gApi.changes().id(changeId).abandon();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
     gApi.changes().id(r.getChangeId()).reviewer(admin.getId().toString()).remove();
   }
 
@@ -1532,7 +2210,7 @@
     in.notify = NotifyHandling.NONE;
 
     // notify unrelated account as TO
-    TestAccount user2 = accounts.user2();
+    TestAccount user2 = accountCreator.user2();
     setApiUser(user);
     recommend(r.getChangeId());
     setApiUser(admin);
@@ -1708,15 +2386,13 @@
     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());
+        Iterables.getOnlyElement(
+            gApi.changes()
+                .query(r.getChangeId())
+                .withOptions(
+                    ALL_REVISIONS, CHANGE_ACTIONS, CURRENT_ACTIONS, DETAILED_LABELS, MESSAGES)
+                .get());
     assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo("Code-Review");
     assertThat(result.messages).hasSize(1);
     assertThat(result.actions).isNotEmpty();
@@ -1736,7 +2412,7 @@
             Iterables.getOnlyElement(query("project:{" + project.get() + "} owner:self")).changeId)
         .isEqualTo(r.getChangeId());
     setApiUser(user);
-    assertThat(query("owner:self")).isEmpty();
+    assertThat(query("owner:self project:{" + project.get() + "}")).isEmpty();
   }
 
   @Test
@@ -1764,12 +2440,41 @@
   }
 
   @Test
+  public void editTopicWithoutPermissionNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("edit topic name not permitted");
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+  }
+
+  @Test
+  public void editTopicWithPermissionAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    grant(project, "refs/heads/master", Permission.EDIT_TOPIC_NAME, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
+  }
+
+  @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();
+    String id = r.getChangeId();
+
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).info();
+    assertThat(c.submitted).isNull();
+    assertThat(c.submitter).isNull();
+
+    gApi.changes().id(id).current().review(ReviewInput.approve());
+    gApi.changes().id(id).current().submit();
+
+    c = gApi.changes().id(r.getChangeId()).info();
+    assertThat(c.submitted).isNotNull();
+    assertThat(c.submitter).isNotNull();
+    assertThat(c.submitter._accountId).isEqualTo(atrScope.get().getUser().getAccountId().get());
   }
 
   @Test
@@ -1790,13 +2495,30 @@
   }
 
   @Test
+  public void submitNotAllowedWithoutPermission() 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("submit not permitted");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+  }
+
+  @Test
+  public void submitAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    grant(project, "refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @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()).get().problems).isNull();
-    assertThat(gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.CHECK)).problems)
-        .isEmpty();
+    assertThat(gApi.changes().id(r.getChangeId()).get(CHECK).problems).isEmpty();
   }
 
   @Test
@@ -1834,9 +2556,7 @@
     in.label("Custom2", 1);
     gApi.changes().id(r2.getChangeId()).current().review(in);
 
-    EnumSet<ListChangesOption> options =
-        EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.COMMIT_FOOTERS);
-    ChangeInfo actual = gApi.changes().id(r2.getChangeId()).get(options);
+    ChangeInfo actual = gApi.changes().id(r2.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
     assertThat(actual.revisions).hasSize(2);
 
     // No footers except on latest patch set.
@@ -1879,9 +2599,7 @@
             });
     ChangeInfo actual;
     try {
-      EnumSet<ListChangesOption> options =
-          EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.COMMIT_FOOTERS);
-      actual = gApi.changes().id(change.getChangeId()).get(options);
+      actual = gApi.changes().id(change.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
     } finally {
       handle.remove();
     }
@@ -1912,7 +2630,6 @@
     gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
 
     createChange();
-    createDraftChange();
 
     setApiUser(user);
     AcceptanceTestRequestScope.Context ctx = disableDb();
@@ -1922,9 +2639,9 @@
                   .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)
+                  .withOption(LABELS)
+                  .withOption(DETAILED_ACCOUNTS)
+                  .withOption(REVIEWED)
                   .get())
           .hasSize(2);
     } finally {
@@ -1937,7 +2654,7 @@
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(triplet).addReviewer(user.username);
-    ChangeInfo c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     LabelInfo codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
@@ -1947,7 +2664,7 @@
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     Util.blockLabel(cfg, "Code-Review", REGISTERED_USERS, "refs/heads/*");
     saveProjectConfig(project, cfg);
-    c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
@@ -1962,10 +2679,7 @@
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 = amendChange(r1.getChangeId());
 
-    ChangeInfo info =
-        gApi.changes()
-            .id(r1.getChangeId())
-            .get(EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.PUSH_CERTIFICATES));
+    ChangeInfo info = gApi.changes().id(r1.getChangeId()).get(ALL_REVISIONS, PUSH_CERTIFICATES);
 
     RevisionInfo rev1 = info.revisions.get(r1.getCommit().name());
     assertThat(rev1).isNotNull();
@@ -2067,62 +2781,6 @@
   }
 
   @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");
@@ -2132,7 +2790,7 @@
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
 
     // Create change as admin
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
@@ -2176,7 +2834,7 @@
     TestRepository<?> adminTestRepo = cloneProject(project, admin);
 
     // Block default permission
-    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
 
     // Create change as admin
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
@@ -2194,62 +2852,6 @@
   }
 
   @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 + ".");
-  }
-
-  @Test
   public void createMergePatchSet() throws Exception {
     PushOneCommit.Result start = pushTo("refs/heads/master");
     start.assertOkStatus();
@@ -2277,13 +2879,7 @@
     in.subject = "update change by merge ps2";
     gApi.changes().id(changeId).createMergePatchSet(in);
     ChangeInfo changeInfo =
-        gApi.changes()
-            .id(changeId)
-            .get(
-                EnumSet.of(
-                    ListChangesOption.ALL_REVISIONS,
-                    ListChangesOption.CURRENT_COMMIT,
-                    ListChangesOption.CURRENT_REVISION));
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
     assertThat(changeInfo.revisions).hasSize(2);
     assertThat(changeInfo.subject).isEqualTo(in.subject);
     assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
@@ -2320,13 +2916,7 @@
     in.inheritParent = true;
     gApi.changes().id(changeId).createMergePatchSet(in);
     ChangeInfo changeInfo =
-        gApi.changes()
-            .id(changeId)
-            .get(
-                EnumSet.of(
-                    ListChangesOption.ALL_REVISIONS,
-                    ListChangesOption.CURRENT_COMMIT,
-                    ListChangesOption.CURRENT_REVISION));
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
 
     assertThat(changeInfo.revisions).hasSize(2);
     assertThat(changeInfo.subject).isEqualTo(in.subject);
@@ -2337,7 +2927,7 @@
   }
 
   @Test
-  public void checkLabelsForOpenChange() throws Exception {
+  public void checkLabelsForUnsubmittedChange() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.NEW);
@@ -2374,6 +2964,14 @@
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly("Code-Review");
     assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+
+    // abandon the change and see that the returned labels stay the same
+    // while all permitted labels disappear.
+    gApi.changes().id(r.getChangeId()).abandon();
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.permittedLabels).isEmpty();
   }
 
   @Test
@@ -2514,17 +3112,6 @@
   }
 
   @Test
-  public void checkLabelsForAbandonedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).abandon();
-
-    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(change.labels).isEmpty();
-    assertThat(change.permittedLabels).isEmpty();
-  }
-
-  @Test
   public void maxPermittedValueAllowed() throws Exception {
     final int minPermittedValue = -2;
     final int maxPermittedValue = +2;
@@ -2535,7 +3122,7 @@
 
     gApi.changes().id(triplet).addReviewer(user.username);
 
-    ChangeInfo c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     LabelInfo codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
@@ -2555,7 +3142,7 @@
         heads);
     saveProjectConfig(project, cfg);
 
-    c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
@@ -2576,7 +3163,7 @@
 
     gApi.changes().id(triplet).addReviewer(user.username);
 
-    ChangeInfo c = gApi.changes().id(triplet).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
     LabelInfo codeReview = c.labels.get("Code-Review");
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
@@ -2584,33 +3171,72 @@
     assertThat(approval.permittedVotingRange).isNull();
   }
 
-  @Sandboxed
+  @Test
+  public void nonStrictLabelWithInvalidLabelPerDefault() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // Add a review with invalid labels.
+    ReviewInput input = ReviewInput.approve().label("Code-Style", 1);
+    gApi.changes().id(changeId).current().review(input);
+
+    Map<String, Short> votes = gApi.changes().id(changeId).current().reviewer(admin.email).votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    assertThat(votes.values()).containsExactly((short) 2);
+  }
+
+  @Test
+  public void nonStrictLabelWithInvalidValuePerDefault() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // Add a review with invalid label values.
+    ReviewInput input = new ReviewInput().label("Code-Review", 3);
+    gApi.changes().id(changeId).current().review(input);
+
+    Map<String, Short> votes = gApi.changes().id(changeId).current().reviewer(admin.email).votes();
+    if (!notesMigration.readChanges()) {
+      assertThat(votes.keySet()).containsExactly("Code-Review");
+      assertThat(votes.values()).containsExactly((short) 0);
+    } else {
+      assertThat(votes).isEmpty();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "change.strictLabels", value = "true")
+  public void strictLabelWithInvalidLabel() throws Exception {
+    String changeId = createChange().getChangeId();
+    ReviewInput in = new ReviewInput().label("Code-Style", 1);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("label \"Code-Style\" is not a configured label");
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  @Test
+  @GerritConfig(name = "change.strictLabels", value = "true")
+  public void strictLabelWithInvalidValue() throws Exception {
+    String changeId = createChange().getChangeId();
+    ReviewInput in = new ReviewInput().label("Code-Review", 3);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("label \"Code-Review\": 3 is not a valid value");
+    gApi.changes().id(changeId).current().review(in);
+  }
+
   @Test
   public void unresolvedCommentsBlocked() throws Exception {
-    RevCommit oldHead = getRemoteHead();
-    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
-    testRepo.reset("config");
-    PushOneCommit push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            "Configure",
-            "rules.pl",
-            "submit_rule(submit(R)) :- \n"
-                + "gerrit:unresolved_comments_count(0), \n"
-                + "!,"
-                + "gerrit:commit_author(A), \n"
-                + "R = label('All-Comments-Resolved', ok(A)).\n"
-                + "submit_rule(submit(R)) :- \n"
-                + "gerrit:unresolved_comments_count(U), \n"
-                + "U > 0,"
-                + "R = label('All-Comments-Resolved', need(_)). \n\n");
+    modifySubmitRules(
+        "submit_rule(submit(R)) :- \n"
+            + "gerrit:unresolved_comments_count(0), \n"
+            + "!,"
+            + "gerrit:commit_author(A), \n"
+            + "R = label('All-Comments-Resolved', ok(A)).\n"
+            + "submit_rule(submit(R)) :- \n"
+            + "gerrit:unresolved_comments_count(U), \n"
+            + "U > 0,"
+            + "R = label('All-Comments-Resolved', need(_)). \n\n");
 
-    push.to(RefNames.REFS_CONFIG);
-    testRepo.reset(oldHead);
-
-    oldHead = getRemoteHead();
+    String oldHead = getRemoteHead().name();
     PushOneCommit.Result result1 =
         pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
@@ -2623,12 +3249,379 @@
     gApi.changes().id(result1.getChangeId()).current().submit();
 
     exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        "Failed to submit 1 change due to the following problems:\n"
-            + "Change 2: needs All-Comments-Resolved");
+    exception.expectMessage("Failed to submit 1 change due to the following problems");
+    exception.expectMessage("needs All-Comments-Resolved");
     gApi.changes().id(result2.getChangeId()).current().submit();
   }
 
+  @Test
+  public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
+    addPureRevertSubmitRule();
+
+    // Create a change that is not a revert of another change
+    PushOneCommit.Result r1 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    approve(r1.getChangeId());
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Failed to submit 1 change due to the following problems");
+    exception.expectMessage("needs Is-Pure-Revert");
+    gApi.changes().id(r1.getChangeId()).current().submit();
+  }
+
+  @Test
+  public void pureRevertFactBlocksSubmissionOfNonPureReverts() throws Exception {
+    PushOneCommit.Result r1 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    merge(r1);
+
+    addPureRevertSubmitRule();
+
+    // Create a revert and push a content change
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    amendChange(revertId);
+    approve(revertId);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Failed to submit 1 change due to the following problems");
+    exception.expectMessage("needs Is-Pure-Revert");
+    gApi.changes().id(revertId).current().submit();
+  }
+
+  @Test
+  public void pureRevertFactAllowsSubmissionOfPureReverts() throws Exception {
+    // Create a change that we can later revert
+    PushOneCommit.Result r1 =
+        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
+    merge(r1);
+
+    addPureRevertSubmitRule();
+
+    // Create a revert and submit it
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    approve(revertId);
+    gApi.changes().id(revertId).current().submit();
+  }
+
+  @Test
+  public void changeCommitMessage() throws Exception {
+    // Tests mutating the commit message as both the owner of the change and a regular user with
+    // addPatchSet permission. Asserts that both cases succeed.
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+
+    for (TestAccount acc : ImmutableList.of(admin, user)) {
+      setApiUser(acc);
+      String newMessage =
+          "modified commit by " + acc.username + "\n\nChange-Id: " + r.getChangeId() + "\n";
+      gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+      RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
+      assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
+      assertThat(getCommitMessage(r.getChangeId())).isEqualTo(newMessage);
+      assertThat(rApi.description()).isEqualTo("Edit commit message");
+    }
+
+    // Verify tags, which should differ according to whether the change was WIP
+    // at the time the commit message was edited. First, look at the last edit
+    // we created above, when the change was not WIP.
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(Iterables.getLast(info.messages).tag)
+        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+
+    // Move the change to WIP and edit the commit message again, to observe a
+    // different tag. Must switch to change owner to move into WIP.
+    setApiUser(admin);
+    gApi.changes().id(r.getChangeId()).setWorkInProgress();
+    String newMessage = "modified commit in WIP change\n\nChange-Id: " + r.getChangeId() + "\n";
+    gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+    info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(Iterables.getLast(info.messages).tag)
+        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
+  }
+
+  @Test
+  public void changeCommitMessageWithNoChangeIdSucceedsIfChangeIdNotRequired() throws Exception {
+    ConfigInput configInput = new ConfigInput();
+    configInput.requireChangeId = InheritableBoolean.FALSE;
+    gApi.projects().name(project.get()).config(configInput);
+
+    PushOneCommit.Result r = createChange();
+    r.assertOkStatus();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+
+    String newMessage = "modified commit\n";
+    gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+    RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
+    assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
+    assertThat(getCommitMessage(r.getChangeId())).isEqualTo(newMessage);
+  }
+
+  @Test
+  public void changeCommitMessageWithNoChangeIdFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("missing Change-Id footer");
+    gApi.changes().id(r.getChangeId()).setMessage("modified commit\n");
+  }
+
+  @Test
+  public void changeCommitMessageWithWrongChangeIdFails() throws Exception {
+    PushOneCommit.Result otherChange = createChange();
+    PushOneCommit.Result r = createChange();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("wrong Change-Id footer");
+    gApi.changes()
+        .id(r.getChangeId())
+        .setMessage("modified commit\n\nChange-Id: " + otherChange.getChangeId() + "\n");
+  }
+
+  @Test
+  public void changeCommitMessageWithoutPermissionFails() throws Exception {
+    // Create new project with clean permissions
+    Project.NameKey p = createProject("addPatchSetEdit");
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
+    // Block default permission
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
+    // Create change as user
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    // Try to change the commit message
+    exception.expect(AuthException.class);
+    exception.expectMessage("modifying commit message not permitted");
+    gApi.changes().id(r.getChangeId()).setMessage("foo");
+  }
+
+  @Test
+  public void changeCommitMessageWithSameMessageFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(getCommitMessage(r.getChangeId()))
+        .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("new and existing commit message are the same");
+    gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId()));
+  }
+
+  @Test
+  public void fourByteEmoji() throws Exception {
+    // U+1F601 GRINNING FACE WITH SMILING EYES
+    String smile = new String(Character.toChars(0x1f601));
+    assertThat(smile).isEqualTo("😁");
+    assertThat(smile).hasLength(2); // Thanks, Java.
+    assertThat(smile.getBytes(UTF_8)).hasLength(4);
+
+    String subject = "A happy change " + smile;
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, subject, FILE_NAME, FILE_CONTENT)
+            .to("refs/for/master");
+    r.assertOkStatus();
+    String id = r.getChangeId();
+
+    ReviewInput ri = ReviewInput.approve();
+    ri.message = "I like it " + smile;
+    ReviewInput.CommentInput ci = new ReviewInput.CommentInput();
+    ci.path = FILE_NAME;
+    ci.side = Side.REVISION;
+    ci.message = "Good " + smile;
+    ri.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(ci));
+    gApi.changes().id(id).current().review(ri);
+
+    ChangeInfo info = gApi.changes().id(id).get(MESSAGES, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(info.subject).isEqualTo(subject);
+    assertThat(Iterables.getLast(info.messages).message).endsWith(ri.message);
+    assertThat(Iterables.getOnlyElement(info.revisions.values()).commit.message)
+        .startsWith(subject);
+
+    List<CommentInfo> comments =
+        Iterables.getOnlyElement(gApi.changes().id(id).comments().values());
+    assertThat(Iterables.getOnlyElement(comments).message).isEqualTo(ci.message);
+  }
+
+  @Test
+  public void pureRevertReturnsTrueForPureRevert() throws Exception {
+    PushOneCommit.Result r = createChange();
+    merge(r);
+    String revertId = gApi.changes().id(r.getChangeId()).revert().get().id;
+    // Without query parameter
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+    // With query parameter
+    assertThat(
+            gApi.changes()
+                .id(revertId)
+                .pureRevert(getRemoteHead().toObjectId().name())
+                .isPureRevert)
+        .isTrue();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseOnContentChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    merge(r1);
+    // Create a revert and expect pureRevert to be true
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+
+    // Create a new PS and expect pureRevert to be false
+    PushOneCommit.Result result = amendChange(revertId);
+    result.assertOkStatus();
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertParameterTakesPrecedence() throws Exception {
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+    String oldHead = getRemoteHead().toObjectId().name();
+
+    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content2");
+    merge(r2);
+
+    String revertId = gApi.changes().id(r2.getChangeId()).revert().get().changeId;
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+    assertThat(gApi.changes().id(revertId).pureRevert(oldHead).isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseOnInvalidInput() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    merge(r1);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid object ID");
+    gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id");
+  }
+
+  @Test
+  public void pureRevertReturnsTrueWithCleanRebase() throws Exception {
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+
+    PushOneCommit.Result r2 = createChange("commit message", "b.txt", "content2");
+    merge(r2);
+
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    // Rebase revert onto HEAD
+    gApi.changes().id(revertId).rebase();
+    // Check that pureRevert is true which implies that the commit can be rebased onto the original
+    // commit.
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseWithRebaseConflict() throws Exception {
+    // Create an initial commit to serve as claimed original
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+    String claimedOriginal = getRemoteHead().toObjectId().name();
+
+    // Change contents of the file to provoke a conflict
+    merge(createChange("commit message", "a.txt", "content2"));
+
+    // Create a commit that we can revert
+    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content3");
+    merge(r2);
+
+    // Create a revert of r2
+    String revertR3Id = gApi.changes().id(r2.getChangeId()).revert().id();
+    // Assert that the change is a pure revert of it's 'revertOf'
+    assertThat(gApi.changes().id(revertR3Id).pureRevert().isPureRevert).isTrue();
+    // Assert that the change is not a pure revert of claimedOriginal because pureRevert is trying
+    // to rebase this on claimed original, which fails.
+    PureRevertInfo pureRevert = gApi.changes().id(revertR3Id).pureRevert(claimedOriginal);
+    assertThat(pureRevert.isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertThrowsExceptionWhenChangeIsNotARevertAndNoIdProvided() throws Exception {
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("no ID was provided and change isn't a revert");
+    gApi.changes().id(createChange().getChangeId()).pureRevert();
+  }
+
+  @Test
+  public void putTopicExceedLimitFails() throws Exception {
+    String changeId = createChange().getChangeId();
+    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("topic length exceeds the limit");
+    gApi.changes().id(changeId).topic(topic);
+  }
+
+  @Test
+  public void submittableAfterLosingPermissions_MaxWithBlock() throws Exception {
+    configLabel("Label", LabelFunction.MAX_WITH_BLOCK);
+    submittableAfterLosingPermissions("Label");
+  }
+
+  @Test
+  public void submittableAfterLosingPermissions_AnyWithBlock() throws Exception {
+    configLabel("Label", LabelFunction.ANY_WITH_BLOCK);
+    submittableAfterLosingPermissions("Label");
+  }
+
+  public void submittableAfterLosingPermissions(String label) throws Exception {
+    String codeReviewLabel = "Code-Review";
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
+    Util.allow(cfg, Permission.forLabel(label), -1, +1, registered, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(codeReviewLabel), -2, +2, registered, "refs/heads/*");
+    saveProjectConfig(cfg);
+
+    setApiUser(user);
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Verify user's permitted range.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertPermitted(change, label, -1, 0, 1);
+    assertPermitted(change, codeReviewLabel, -2, -1, 0, 1, 2);
+
+    ReviewInput input = new ReviewInput();
+    input.label(codeReviewLabel, 2);
+    input.label(label, 1);
+    gApi.changes().id(changeId).current().review(input);
+
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email).votes().keySet())
+        .containsExactly(codeReviewLabel, label);
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email).votes().values())
+        .containsExactly((short) 2, (short) 1);
+    assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
+
+    setApiUser(admin);
+    // Remove user's permission for 'Label'.
+    Util.remove(cfg, Permission.forLabel(label), registered, "refs/heads/*");
+    // Update user's permitted range for 'Code-Review' to be -1...+1.
+    Util.remove(cfg, Permission.forLabel(codeReviewLabel), registered, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(codeReviewLabel), -1, +1, registered, "refs/heads/*");
+    saveProjectConfig(cfg);
+
+    // Verify user's new permitted range.
+    setApiUser(user);
+    change = gApi.changes().id(changeId).get();
+    assertPermitted(change, label);
+    assertPermitted(change, codeReviewLabel, -1, 0, 1);
+
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email).votes().values())
+        .containsExactly((short) 2, (short) 1);
+    assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
+
+    setApiUser(admin);
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  private String getCommitMessage(String changeId) throws RestApiException, IOException {
+    return gApi.changes().id(changeId).current().file("/COMMIT_MSG").content().asString();
+  }
+
   private void addComment(
       PushOneCommit.Result r,
       String message,
@@ -2654,14 +3647,12 @@
   }
 
   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));
+    return parseChangeResource(r.getChangeId());
   }
 
   private Optional<ReviewerState> getReviewerState(String changeId, Account.Id accountId)
       throws Exception {
-    ChangeInfo c = gApi.changes().id(changeId).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    ChangeInfo c = gApi.changes().id(changeId).get(DETAILED_LABELS);
     Set<ReviewerState> states =
         c.reviewers.entrySet().stream()
             .filter(e -> e.getValue().stream().anyMatch(a -> a._accountId == accountId.get()))
@@ -2673,7 +3664,7 @@
 
   private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
     try (BatchUpdate batchUpdate =
-        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
       batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
       batchUpdate.execute();
     }
@@ -2703,4 +3694,289 @@
       return true;
     }
   }
+
+  private void addPureRevertSubmitRule() throws Exception {
+    modifySubmitRules(
+        "submit_rule(submit(R)) :- \n"
+            + "gerrit:pure_revert(1), \n"
+            + "!,"
+            + "gerrit:commit_author(A), \n"
+            + "R = label('Is-Pure-Revert', ok(A)).\n"
+            + "submit_rule(submit(R)) :- \n"
+            + "gerrit:pure_revert(U), \n"
+            + "U \\= 1,"
+            + "R = label('Is-Pure-Revert', need(_)). \n\n");
+  }
+
+  private void modifySubmitRules(String newContent) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      TestRepository<?> testRepo = new TestRepository<>((InMemoryRepository) repo);
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .author(admin.getIdent())
+          .committer(admin.getIdent())
+          .add("rules.pl", newContent)
+          .message("Modify rules.pl")
+          .create();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "trackingid.jira-bug.footer", value = "Bug:")
+  @GerritConfig(name = "trackingid.jira-bug.match", value = "JRA\\d{2,8}")
+  @GerritConfig(name = "trackingid.jira-bug.system", value = "JIRA")
+  public void trackingIds() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT + "\n\n" + "Bug:JRA001",
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get(TRACKING_IDS);
+    Collection<TrackingIdInfo> trackingIds = change.trackingIds;
+    assertThat(trackingIds).isNotNull();
+    assertThat(trackingIds).hasSize(1);
+    assertThat(trackingIds.iterator().next().system).isEqualTo("JIRA");
+    assertThat(trackingIds.iterator().next().id).isEqualTo("JRA001");
+  }
+
+  @Test
+  public void starUnstar() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    changeIndexedCounter.clear();
+
+    gApi.accounts().self().starChange(triplet);
+    ChangeInfo change = info(triplet);
+    assertThat(change.starred).isTrue();
+    assertThat(change.stars).contains(DEFAULT_LABEL);
+    changeIndexedCounter.assertReindexOf(change);
+
+    gApi.accounts().self().unstarChange(triplet);
+    change = info(triplet);
+    assertThat(change.starred).isNull();
+    assertThat(change.stars).isNull();
+    changeIndexedCounter.assertReindexOf(change);
+  }
+
+  @Test
+  public void ignore() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    in = new AddReviewerInput();
+    in.reviewer = user2.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).ignore(true);
+    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isTrue();
+
+    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);
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).ignore(false);
+    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isFalse();
+  }
+
+  @Test
+  public void cannotIgnoreOwnChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("cannot ignore own change");
+    gApi.changes().id(changeId).ignore(true);
+  }
+
+  @Test
+  public void cannotIgnoreStarredChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.accounts().self().starChange(changeId);
+    assertThat(gApi.changes().id(changeId).get().starred).isTrue();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.DEFAULT_LABEL
+            + " and "
+            + StarredChangesUtil.IGNORE_LABEL
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.changes().id(changeId).ignore(true);
+  }
+
+  @Test
+  public void cannotStarIgnoredChange() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).ignore(true);
+    assertThat(gApi.changes().id(changeId).ignored()).isTrue();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.DEFAULT_LABEL
+            + " and "
+            + StarredChangesUtil.IGNORE_LABEL
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.accounts().self().starChange(changeId);
+  }
+
+  @Test
+  public void markAsReviewed() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    setApiUser(user);
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
+    gApi.changes().id(r.getChangeId()).markAsReviewed(true);
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isTrue();
+
+    setApiUser(user2);
+    sender.clear();
+    amendChange(r.getChangeId());
+
+    setApiUser(user);
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
+
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    assertThat(messages.get(0).rcpt()).containsExactly(user.emailAddress);
+  }
+
+  @Test
+  public void cannotSetUnreviewedLabelForPatchSetThatAlreadyHasReviewedLabel() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).markAsReviewed(true);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.REVIEWED_LABEL
+            + "/"
+            + 1
+            + " and "
+            + StarredChangesUtil.UNREVIEWED_LABEL
+            + "/"
+            + 1
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.accounts()
+        .self()
+        .setStars(
+            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1")));
+  }
+
+  @Test
+  public void cannotSetReviewedLabelForPatchSetThatAlreadyHasUnreviewedLabel() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).markAsReviewed(false);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "The labels "
+            + StarredChangesUtil.REVIEWED_LABEL
+            + "/"
+            + 1
+            + " and "
+            + StarredChangesUtil.UNREVIEWED_LABEL
+            + "/"
+            + 1
+            + " are mutually exclusive. Only one of them can be set.");
+    gApi.accounts()
+        .self()
+        .setStars(
+            changeId, new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1")));
+  }
+
+  @Test
+  public void setReviewedAndUnreviewedLabelsForDifferentPatchSets() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    setApiUser(user);
+    gApi.changes().id(changeId).markAsReviewed(true);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
+
+    amendChange(changeId);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
+
+    gApi.changes().id(changeId).markAsReviewed(false);
+    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
+
+    assertThat(gApi.accounts().self().getStars(changeId))
+        .containsExactly(
+            StarredChangesUtil.REVIEWED_LABEL + "/" + 1,
+            StarredChangesUtil.UNREVIEWED_LABEL + "/" + 2);
+  }
+
+  @Test
+  public void cannotSetInvalidLabel() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // label cannot contain whitespace
+    String invalidLabel = "invalid label";
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid labels: " + invalidLabel);
+    gApi.accounts().self().setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel)));
+  }
+
+  private static class ChangeIndexedCounter implements ChangeIndexedListener {
+    private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
+
+    @Override
+    public void onChangeIndexed(String projectName, int id) {
+      countsByChange.incrementAndGet(id);
+    }
+
+    @Override
+    public void onChangeDeleted(int id) {
+      countsByChange.incrementAndGet(id);
+    }
+
+    void clear() {
+      countsByChange.clear();
+    }
+
+    long getCount(ChangeInfo info) {
+      return countsByChange.get(info._number);
+    }
+
+    void assertReindexOf(ChangeInfo info) {
+      assertReindexOf(info, 1);
+    }
+
+    void assertReindexOf(ChangeInfo info, int expectedCount) {
+      assertThat(getCount(info)).isEqualTo(expectedCount);
+      assertThat(countsByChange).hasSize(1);
+      clear();
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
new file mode 100644
index 0000000..e0fc358
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -0,0 +1,122 @@
+// 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.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.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Project;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeIdIT extends AbstractDaemonTest {
+  private ChangeInfo changeInfo;
+
+  @Before
+  public void setup() throws Exception {
+    changeInfo = gApi.changes().create(new ChangeInput(project.get(), "master", "msg")).get();
+  }
+
+  @Test
+  public void projectChangeNumberReturnsChange() throws Exception {
+    ChangeApi cApi = gApi.changes().id(project.get(), changeInfo._number);
+    assertThat(cApi.get().changeId).isEqualTo(changeInfo.changeId);
+  }
+
+  @Test
+  public void projectChangeNumberReturnsChangeWhenProjectContainsSlashes() throws Exception {
+    Project.NameKey p = createProject("foo/bar");
+    ChangeInfo ci = gApi.changes().create(new ChangeInput(p.get(), "master", "msg")).get();
+    ChangeApi cApi = gApi.changes().id(p.get(), ci._number);
+    assertThat(cApi.get().changeId).isEqualTo(ci.changeId);
+  }
+
+  @Test
+  public void wrongProjectInProjectChangeNumberReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: unknown~" + changeInfo._number);
+    gApi.changes().id("unknown", changeInfo._number);
+  }
+
+  @Test
+  public void wrongIdInProjectChangeNumberReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + project.get() + "~" + Integer.MAX_VALUE);
+    gApi.changes().id(project.get(), Integer.MAX_VALUE);
+  }
+
+  @Test
+  public void changeNumberReturnsChange() throws Exception {
+    ChangeApi cApi = gApi.changes().id(changeInfo._number);
+    assertThat(cApi.get().changeId).isEqualTo(changeInfo.changeId);
+  }
+
+  @Test
+  public void wrongChangeNumberReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(Integer.MAX_VALUE);
+  }
+
+  @Test
+  public void tripletChangeIdReturnsChange() throws Exception {
+    ChangeApi cApi = gApi.changes().id(project.get(), changeInfo.branch, changeInfo.changeId);
+    assertThat(cApi.get().changeId).isEqualTo(changeInfo.changeId);
+  }
+
+  @Test
+  public void wrongProjectInTripletChangeIdReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: unknown~" + changeInfo.branch + "~" + changeInfo.changeId);
+    gApi.changes().id("unknown", changeInfo.branch, changeInfo.changeId);
+  }
+
+  @Test
+  public void wrongBranchInTripletChangeIdReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + project.get() + "~unknown~" + changeInfo.changeId);
+    gApi.changes().id(project.get(), "unknown", changeInfo.changeId);
+  }
+
+  @Test
+  public void wrongIdInTripletChangeIdReturnsNotFound() throws Exception {
+    String unknownId = "I1234567890";
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage(
+        "Not found: " + project.get() + "~" + changeInfo.branch + "~" + unknownId);
+    gApi.changes().id(project.get(), changeInfo.branch, unknownId);
+  }
+
+  @Test
+  public void changeIdReturnsChange() throws Exception {
+    // ChangeId is not unique and this method needs a unique changeId to work.
+    // Hence we generate a new change with a different content.
+    ChangeInfo ci =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "different message")).get();
+    ChangeApi cApi = gApi.changes().id(ci.changeId);
+    assertThat(cApi.get()._number).isEqualTo(ci._number);
+  }
+
+  @Test
+  public void wrongChangeIdReturnsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id("I1234567890");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
new file mode 100644
index 0000000..92de781
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/DisablePrivateChangesIT.java
@@ -0,0 +1,121 @@
+// 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.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class DisablePrivateChangesIT extends AbstractDaemonTest {
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void createPrivateChangeWithDisablePrivateChangesTrue() throws Exception {
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    input.isPrivate = true;
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("private changes are disabled");
+    gApi.changes().create(input);
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void createNonPrivateChangeWithDisablePrivateChangesTrue() throws Exception {
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void createPrivateChangeWithDisablePrivateChangesFalse() throws Exception {
+    ChangeInput input = new ChangeInput(project.get(), "master", "empty change");
+    input.isPrivate = true;
+    assertThat(gApi.changes().create(input).get().isPrivate).isEqualTo(true);
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushPrivatesWithDisablePrivateChangesTrue() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%private");
+    result.assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "change.allowDrafts", value = "true")
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushDraftsWithDisablePrivateChangesTrue() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%draft");
+    result.assertErrorStatus();
+
+    testRepo.reset(initialHead);
+    result = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    result.assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushWithDisablePrivateChangesTrue() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master");
+    result.assertOkStatus();
+    assertThat(result.getChange().change().isPrivate()).isFalse();
+  }
+
+  @Test
+  @GerritConfig(name = "change.allowDrafts", value = "true")
+  public void pushPrivatesWithDisablePrivateChangesFalse() throws Exception {
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%private");
+    assertThat(result.getChange().change().isPrivate()).isEqualTo(true);
+  }
+
+  @Test
+  @GerritConfig(name = "change.allowDrafts", value = "true")
+  public void pushDraftsWithDisablePrivateChangesFalse() throws Exception {
+    RevCommit initialHead = getRemoteHead();
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%draft");
+    assertThat(result.getChange().change().isPrivate()).isEqualTo(true);
+
+    testRepo.reset(initialHead);
+    result = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    assertThat(result.getChange().change().isPrivate()).isEqualTo(true);
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void setPrivateWithDisablePrivateChangesTrue() throws Exception {
+    PushOneCommit.Result result = createChange();
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("private changes are disabled");
+    gApi.changes().id(result.getChangeId()).setPrivate(true, "set private");
+  }
+
+  @Test
+  public void setPrivateWithDisablePrivateChangesFalse() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).setPrivate(true, "set private");
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+  }
+}
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
index 94f8494..bfc07db 100644
--- 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
@@ -20,6 +20,9 @@
 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.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 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;
@@ -38,7 +41,6 @@
 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;
@@ -228,6 +230,22 @@
   }
 
   @Test
+  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.getLabelSections().get("Code-Review").setCopyAllScoresIfNoChange(true);
+    saveProjectConfig(project, cfg);
+
+    String changeId = createChangeForMergeCommit();
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    updateSecondParent(changeId);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, null);
+    assertVotes(c, user, 0, 0, null);
+  }
+
+  @Test
   public void removedVotesNotSticky() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     cfg.getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
@@ -336,13 +354,7 @@
   }
 
   private ChangeInfo detailedChange(String changeId) throws Exception {
-    return gApi.changes()
-        .id(changeId)
-        .get(
-            EnumSet.of(
-                ListChangesOption.DETAILED_LABELS,
-                ListChangesOption.CURRENT_REVISION,
-                ListChangesOption.CURRENT_COMMIT));
+    return gApi.changes().id(changeId).get(DETAILED_LABELS, CURRENT_REVISION, CURRENT_COMMIT);
   }
 
   private void assertNotSticky(Set<ChangeKind> changeKinds) throws Exception {
@@ -494,6 +506,24 @@
     assertThat(getChangeKind(changeId)).isEqualTo(MERGE_FIRST_PARENT_UPDATE);
   }
 
+  private void updateSecondParent(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 commitParent1 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent1));
+
+    testRepo.reset(parent2);
+    PushOneCommit.Result newParent2 = createChange("new parent 2", "p2-2.txt", "content 2-2");
+
+    PushOneCommit merge = pushFactory.create(db, admin.getIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(commitParent1, newParent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
+  }
+
   private String cherryPick(String changeId, ChangeKind changeKind) throws Exception {
     switch (changeKind) {
       case REWORK:
@@ -533,7 +563,7 @@
   }
 
   private ChangeKind getChangeKind(String changeId) throws Exception {
-    ChangeInfo c = gApi.changes().id(changeId).get(EnumSet.of(ListChangesOption.CURRENT_REVISION));
+    ChangeInfo c = gApi.changes().id(changeId).get(CURRENT_REVISION);
     return c.revisions.get(c.currentRevision).kind;
   }
 
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
index 2337246..54b2a47 100644
--- 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
@@ -41,7 +41,7 @@
         assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
       }
     }
-    accountCache.evictAll();
+    accountCache.evictAllNoReindex();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
index c9d5a8f..dd891ce 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
@@ -15,23 +15,23 @@
 package com.google.gerrit.acceptance.api.group;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.group.InternalGroup;
 import java.util.Set;
 
 public class GroupAssert {
 
   public static void assertGroups(Iterable<String> expected, Set<String> actual) {
     for (String g : expected) {
-      assert_().withFailureMessage("missing group " + g).that(actual.remove(g)).isTrue();
+      assertWithMessage("missing group " + g).that(actual.remove(g)).isTrue();
     }
-    assert_().withFailureMessage("unexpected groups: " + actual).that(actual).isEmpty();
+    assertWithMessage("unexpected groups: " + actual).that(actual).isEmpty();
   }
 
-  public static void assertGroupInfo(AccountGroup group, GroupInfo info) {
+  public static void assertGroupInfo(InternalGroup group, GroupInfo info) {
     if (info.name != null) {
       // 'name' is not set if returned in a map
       assertThat(info.name).isEqualTo(group.getName());
@@ -42,6 +42,7 @@
     assertThat(toBoolean(info.options.visibleToAll)).isEqualTo(group.isVisibleToAll());
     assertThat(info.description).isEqualTo(group.getDescription());
     assertThat(Url.decode(info.ownerId)).isEqualTo(group.getOwnerGroupUUID().get());
+    assertThat(info.createdOn).isEqualTo(group.getCreatedOn());
   }
 
   public static boolean toBoolean(Boolean b) {
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 76e1160..3f18f64 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
@@ -26,10 +26,11 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.api.groups.Groups;
+import com.google.gerrit.extensions.api.groups.Groups.ListRequest;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo.GroupMemberAuditEventInfo;
@@ -45,10 +46,18 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupIncludeCache;
+import com.google.gerrit.server.group.Groups;
+import com.google.gerrit.server.group.GroupsUpdate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.ServerInitiated;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -56,6 +65,16 @@
 
 @NoHttpd
 public class GroupsIT extends AbstractDaemonTest {
+  @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdateProvider;
+  @Inject private Groups groups;
+  @Inject private GroupIncludeCache groupIncludeCache;
+
+  @Test
+  public void systemGroupCanBeRetrievedFromIndex() throws Exception {
+    List<GroupInfo> groupInfos = gApi.groups().query("name:Administrators").get();
+    assertThat(groupInfos).isNotEmpty();
+  }
+
   @Test
   public void addToNonExistingGroup_NotFound() throws Exception {
     exception.expect(ResourceNotFoundException.class);
@@ -79,6 +98,26 @@
   }
 
   @Test
+  public void cachedGroupsForMemberAreUpdatedOnMemberAdditionAndRemoval() throws Exception {
+    // Fill the cache for the observed account.
+    groupIncludeCache.getGroupsWithMember(user.getId());
+    String groupName = createGroup("users");
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID(gApi.groups().id(groupName).get().id);
+
+    gApi.groups().id(groupName).addMembers(user.fullName);
+
+    Collection<AccountGroup.UUID> groupsWithMemberAfterAddition =
+        groupIncludeCache.getGroupsWithMember(user.getId());
+    assertThat(groupsWithMemberAfterAddition).contains(groupUuid);
+
+    gApi.groups().id(groupName).removeMembers(user.fullName);
+
+    Collection<AccountGroup.UUID> groupsWithMemberAfterRemoval =
+        groupIncludeCache.getGroupsWithMember(user.getId());
+    assertThat(groupsWithMemberAfterRemoval).doesNotContain(groupUuid);
+  }
+
+  @Test
   public void addExistingMember_OK() throws Exception {
     String g = "Administrators";
     assertMembers(g, admin);
@@ -95,8 +134,8 @@
   @Test
   public void addMultipleMembers() throws Exception {
     String g = createGroup("users");
-    TestAccount u1 = accounts.create("u1", "u1@example.com", "Full Name 1");
-    TestAccount u2 = accounts.create("u2", "u2@example.com", "Full Name 2");
+    TestAccount u1 = accountCreator.create("u1", "u1@example.com", "Full Name 1");
+    TestAccount u2 = accountCreator.create("u2", "u2@example.com", "Full Name 2");
     gApi.groups().id(g).addMembers(u1.username, u2.username);
     assertMembers(g, u1, u2);
   }
@@ -104,10 +143,10 @@
   @Test
   public void addMembersWithAtSign() throws Exception {
     String g = createGroup("users");
-    TestAccount u10 = accounts.create("u10", "u10@example.com", "Full Name 10");
+    TestAccount u10 = accountCreator.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");
+        accountCreator.create("u11@something", "u11@example.com", "Full Name 11 With At");
+    accountCreator.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);
   }
@@ -224,14 +263,33 @@
   }
 
   @Test
+  public void createdOnFieldIsPopulatedForNewGroup() throws Exception {
+    Timestamp testStartTime = TimeUtil.nowTs();
+    String newGroupName = name("newGroup");
+    GroupInfo group = gApi.groups().create(newGroupName).get();
+
+    assertThat(group.createdOn).isAtLeast(testStartTime);
+  }
+
+  @Test
+  public void createdOnFieldDefaultsToAuditCreationInstantBeforeSchemaUpgrade() throws Exception {
+    String newGroupName = name("newGroup");
+    GroupInfo newGroup = gApi.groups().create(newGroupName).get();
+    setCreatedOnToNull(new AccountGroup.UUID(newGroup.id));
+
+    GroupInfo updatedGroup = gApi.groups().id(newGroup.id).get();
+    assertThat(updatedGroup.createdOn).isEqualTo(AccountGroup.auditCreationInstantTs());
+  }
+
+  @Test
   public void getGroup() throws Exception {
-    AccountGroup adminGroup = groupCache.get(new AccountGroup.NameKey("Administrators"));
+    InternalGroup adminGroup = getFromCache("Administrators");
     testGetGroup(adminGroup.getGroupUUID().get(), adminGroup);
     testGetGroup(adminGroup.getName(), adminGroup);
     testGetGroup(adminGroup.getId().get(), adminGroup);
   }
 
-  private void testGetGroup(Object id, AccountGroup expectedGroup) throws Exception {
+  private void testGetGroup(Object id, InternalGroup expectedGroup) throws Exception {
     GroupInfo group = gApi.groups().id(id.toString()).get();
     assertGroupInfo(expectedGroup, group);
   }
@@ -448,7 +506,7 @@
   @Test
   public void listAllGroups() throws Exception {
     List<String> expectedGroups =
-        groupCache.all().stream().map(a -> a.getName()).sorted().collect(toList());
+        groups.getAll(db).map(a -> a.getName()).sorted().collect(toList());
     assertThat(expectedGroups.size()).isAtLeast(2);
     assertThat(gApi.groups().list().getAsMap().keySet())
         .containsExactlyElementsIn(expectedGroups)
@@ -481,6 +539,7 @@
     assertThat(groups).containsKey("Administrators");
     assertThat(groups).hasSize(1);
     assertBadRequest(gApi.groups().list().withSuggest("adm").withSubstring("foo"));
+    assertBadRequest(gApi.groups().list().withSuggest("adm").withRegex("foo.*"));
     assertBadRequest(gApi.groups().list().withSuggest("adm").withUser("user"));
     assertBadRequest(gApi.groups().list().withSuggest("adm").withOwned(true));
     assertBadRequest(gApi.groups().list().withSuggest("adm").withVisibleToAll(true));
@@ -509,8 +568,24 @@
   }
 
   @Test
+  public void withRegex() throws Exception {
+    Map<String, GroupInfo> groups = gApi.groups().list().withRegex("Admin.*").getAsMap();
+    assertThat(groups).containsKey("Administrators");
+    assertThat(groups).hasSize(1);
+
+    groups = gApi.groups().list().withRegex("admin.*").getAsMap();
+    assertThat(groups).isEmpty();
+
+    groups = gApi.groups().list().withRegex(".*istrators").getAsMap();
+    assertThat(groups).containsKey("Administrators");
+    assertThat(groups).hasSize(1);
+
+    assertBadRequest(gApi.groups().list().withRegex(".*istrators").withSubstring("s"));
+  }
+
+  @Test
   public void allGroupInfoFieldsSetCorrectly() throws Exception {
-    AccountGroup adminGroup = getFromCache("Administrators");
+    InternalGroup adminGroup = getFromCache("Administrators");
     Map<String, GroupInfo> groups = gApi.groups().list().addGroup(adminGroup.getName()).getAsMap();
     assertThat(groups).hasSize(1);
     assertThat(groups).containsKey("Administrators");
@@ -558,7 +633,7 @@
   // reindex is tested by {@link AbstractQueryGroupsTest#reindex}
   @Test
   public void reindexPermissions() throws Exception {
-    TestAccount groupOwner = accounts.user2();
+    TestAccount groupOwner = accountCreator.user2();
     GroupInput in = new GroupInput();
     in.name = name("group");
     in.members =
@@ -634,17 +709,15 @@
     assertThat(gApi.groups().id(group).includedGroups()).isEmpty();
   }
 
-  private AccountGroup getFromCache(String name) throws Exception {
-    return groupCache.get(new AccountGroup.NameKey(name));
+  private InternalGroup getFromCache(String name) throws Exception {
+    return groupCache.get(new AccountGroup.NameKey(name)).orElse(null);
   }
 
-  private String createAccount(String name, String group) throws Exception {
-    name = name(name);
-    accounts.create(name, group);
-    return name;
+  private void setCreatedOnToNull(AccountGroup.UUID groupUuid) throws Exception {
+    groupsUpdateProvider.get().updateGroup(db, groupUuid, group -> group.setCreatedOn(null));
   }
 
-  private void assertBadRequest(Groups.ListRequest req) throws Exception {
+  private void assertBadRequest(ListRequest req) throws Exception {
     try {
       req.get();
       fail("Expected BadRequestException");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/BUILD
new file mode 100644
index 0000000..148fb2a
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/BUILD
@@ -0,0 +1,7 @@
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "api_plugin",
+    labels = ["api"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
new file mode 100644
index 0000000..0fa09af
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -0,0 +1,155 @@
+// 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.acceptance.api.plugin;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.plugins.PluginApi;
+import com.google.gerrit.extensions.api.plugins.Plugins.ListRequest;
+import com.google.gerrit.extensions.common.InstallPluginInput;
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RawInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+public class PluginIT extends AbstractDaemonTest {
+  private static final String JS_PLUGIN = "Gerrit.install(function(self){});\n";
+  private static final String HTML_PLUGIN =
+      String.format("<dom-module id=\"test\"><script>%s</script></dom-module>", JS_PLUGIN);
+  private static final RawInput JS_PLUGIN_CONTENT = RawInputUtil.create(JS_PLUGIN.getBytes(UTF_8));
+  private static final RawInput HTML_PLUGIN_CONTENT =
+      RawInputUtil.create(HTML_PLUGIN.getBytes(UTF_8));
+
+  private static final List<String> PLUGINS =
+      ImmutableList.of(
+          "plugin-a.js", "plugin-b.html", "plugin-c.js", "plugin-d.html", "plugin_e.js");
+
+  @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
+  public void pluginManagement() throws Exception {
+    // No plugins are loaded
+    assertThat(list().get()).isEmpty();
+    assertThat(list().all().get()).isEmpty();
+
+    PluginApi api;
+    // Install all the plugins
+    InstallPluginInput input = new InstallPluginInput();
+    for (String plugin : PLUGINS) {
+      input.raw = plugin.endsWith(".js") ? JS_PLUGIN_CONTENT : HTML_PLUGIN_CONTENT;
+      api = gApi.plugins().install(plugin, input);
+      assertThat(api).isNotNull();
+      PluginInfo info = api.get();
+      String name = pluginName(plugin);
+      assertThat(info.id).isEqualTo(name);
+      assertThat(info.version).isEqualTo(pluginVersion(plugin));
+      assertThat(info.indexUrl).isEqualTo(String.format("plugins/%s/", name));
+      assertThat(info.filename).isEqualTo(plugin);
+      assertThat(info.disabled).isNull();
+    }
+    assertPlugins(list().get(), PLUGINS);
+
+    // With pagination
+    assertPlugins(list().start(1).limit(2).get(), PLUGINS.subList(1, 3));
+
+    // With prefix
+    assertPlugins(list().prefix("plugin-b").get(), ImmutableList.of("plugin-b.html"));
+    assertPlugins(list().prefix("PLUGIN-").get(), ImmutableList.of());
+
+    // With substring
+    assertPlugins(list().substring("lugin-").get(), PLUGINS.subList(0, PLUGINS.size() - 1));
+    assertPlugins(list().substring("lugin-").start(1).limit(2).get(), PLUGINS.subList(1, 3));
+
+    // With regex
+    assertPlugins(list().regex(".*in-b").get(), ImmutableList.of("plugin-b.html"));
+    assertPlugins(list().regex("plugin-.*").get(), PLUGINS.subList(0, PLUGINS.size() - 1));
+    assertPlugins(list().regex("plugin-.*").start(1).limit(2).get(), PLUGINS.subList(1, 3));
+
+    // Invalid match combinations
+    assertBadRequest(list().regex(".*in-b").substring("a"));
+    assertBadRequest(list().regex(".*in-b").prefix("a"));
+    assertBadRequest(list().substring(".*in-b").prefix("a"));
+
+    // Disable
+    api = gApi.plugins().name("plugin-a");
+    api.disable();
+    api = gApi.plugins().name("plugin-a");
+    assertThat(api.get().disabled).isTrue();
+    assertPlugins(list().get(), PLUGINS.subList(1, PLUGINS.size()));
+    assertPlugins(list().all().get(), PLUGINS);
+
+    // Enable
+    api.enable();
+    api = gApi.plugins().name("plugin-a");
+    assertThat(api.get().disabled).isNull();
+    assertPlugins(list().get(), PLUGINS);
+  }
+
+  @Test
+  public void installNotAllowed() throws Exception {
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("remote installation is disabled");
+    gApi.plugins().install("test.js", new InstallPluginInput());
+  }
+
+  @Test
+  public void getNonExistingThrowsNotFound() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    gApi.plugins().name("does-not-exist");
+  }
+
+  private ListRequest list() throws RestApiException {
+    return gApi.plugins().list();
+  }
+
+  private void assertPlugins(List<PluginInfo> actual, List<String> expected) {
+    List<String> _actual = actual.stream().map(p -> p.id).collect(toList());
+    List<String> _expected = expected.stream().map(p -> pluginName(p)).collect(toList());
+    assertThat(_actual).containsExactlyElementsIn(_expected);
+  }
+
+  private String pluginName(String plugin) {
+    int dot = plugin.indexOf(".");
+    assertThat(dot).isGreaterThan(0);
+    return plugin.substring(0, dot);
+  }
+
+  private String pluginVersion(String plugin) {
+    String name = pluginName(plugin);
+    int dash = name.lastIndexOf("-");
+    return dash > 0 ? name.substring(dash + 1) : "";
+  }
+
+  private void assertBadRequest(ListRequest req) throws Exception {
+    try {
+      req.get();
+      fail("Expected BadRequestException");
+    } catch (BadRequestException e) {
+      // Expected
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
new file mode 100644
index 0000000..2f92e7a
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -0,0 +1,154 @@
+// 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.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckAccessIT extends AbstractDaemonTest {
+
+  private Project.NameKey normalProject;
+  private Project.NameKey secretProject;
+  private Project.NameKey secretRefProject;
+  private TestAccount privilegedUser;
+  private InternalGroup privilegedGroup;
+
+  @Before
+  public void setUp() throws Exception {
+    normalProject = createProject("normal");
+    secretProject = createProject("secret");
+    secretRefProject = createProject("secretRef");
+    privilegedGroup =
+        groupCache.get(new AccountGroup.NameKey(createGroup("privilegedGroup"))).orElse(null);
+
+    privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
+    gApi.groups().id(privilegedGroup.getGroupUUID().get()).addMembers(privilegedUser.username);
+
+    assertThat(gApi.groups().id(privilegedGroup.getGroupUUID().get()).members().get(0).email)
+        .contains("snowden");
+
+    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroup.getGroupUUID());
+    block(secretProject, "refs/*", Permission.READ, SystemGroupBackend.REGISTERED_USERS);
+
+    // deny/grant/block arg ordering is screwy.
+    deny(secretRefProject, "refs/*", Permission.READ, SystemGroupBackend.ANONYMOUS_USERS);
+    grant(
+        secretRefProject,
+        "refs/heads/secret/*",
+        Permission.READ,
+        false,
+        privilegedGroup.getGroupUUID());
+    block(
+        secretRefProject,
+        "refs/heads/secret/*",
+        Permission.READ,
+        SystemGroupBackend.REGISTERED_USERS);
+    grant(
+        secretRefProject,
+        "refs/heads/*",
+        Permission.READ,
+        false,
+        SystemGroupBackend.REGISTERED_USERS);
+  }
+
+  @Test
+  public void emptyInput() throws Exception {
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("input requires 'account'");
+    gApi.projects().name(normalProject.get()).checkAccess(new AccessCheckInput());
+  }
+
+  @Test
+  public void nonexistentEmail() throws Exception {
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("cannot find account doesnotexist@invalid.com");
+    gApi.projects()
+        .name(normalProject.get())
+        .checkAccess(new AccessCheckInput("doesnotexist@invalid.com", null));
+  }
+
+  private static class TestCase {
+    AccessCheckInput input;
+    String project;
+    int want;
+
+    TestCase(String mail, String project, String ref, int want) {
+      this.input = new AccessCheckInput(mail, ref);
+      this.project = project;
+      this.want = want;
+    }
+  }
+
+  @Test
+  public void accessible() throws Exception {
+    List<TestCase> inputs =
+        ImmutableList.of(
+            new TestCase(user.email, normalProject.get(), null, 200),
+            new TestCase(user.email, secretProject.get(), null, 403),
+            new TestCase(user.email, secretRefProject.get(), "refs/heads/secret/master", 403),
+            new TestCase(
+                privilegedUser.email, secretRefProject.get(), "refs/heads/secret/master", 200),
+            new TestCase(privilegedUser.email, normalProject.get(), null, 200),
+            new TestCase(privilegedUser.email, secretProject.get(), null, 200));
+
+    for (TestCase tc : inputs) {
+      String in = newGson().toJson(tc.input);
+      AccessCheckInfo info = null;
+
+      try {
+        info = gApi.projects().name(tc.project).checkAccess(tc.input);
+      } catch (RestApiException e) {
+        fail(String.format("check.access(%s, %s): exception %s", tc.project, in, e));
+      }
+
+      int want = tc.want;
+      if (want != info.status) {
+        fail(
+            String.format("check.access(%s, %s) = %d, want %d", tc.project, in, info.status, want));
+      }
+
+      switch (want) {
+        case 403:
+          assertThat(info.message).contains("cannot see");
+          break;
+        case 404:
+          assertThat(info.message).contains("does not exist");
+          break;
+        case 200:
+          assertThat(info.message).isNull();
+          break;
+        default:
+          fail(String.format("unknown code %d", want));
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CommitIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CommitIT.java
new file mode 100644
index 0000000..64b31dc
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CommitIT.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.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.reviewdb.client.Branch;
+import java.util.Iterator;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+@NoHttpd
+public class CommitIT extends AbstractDaemonTest {
+  @Test
+  public void getCommitInfo() throws Exception {
+    Result result = createChange();
+    String commitId = result.getCommit().getId().name();
+    CommitInfo info = gApi.projects().name(project.get()).commit(commitId).get();
+    assertThat(info.commit).isEqualTo(commitId);
+    assertThat(info.parents.stream().map(c -> c.commit).collect(toList()))
+        .containsExactly(result.getCommit().getParent(0).name());
+    assertThat(info.subject).isEqualTo(result.getCommit().getShortMessage());
+    assertPerson(info.author, admin);
+    assertPerson(info.committer, admin);
+    assertThat(info.webLinks).isNull();
+  }
+
+  @Test
+  public void includedInOpenChange() throws Exception {
+    Result result = createChange();
+    assertThat(getIncludedIn(result.getCommit().getId()).branches).isEmpty();
+    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
+  }
+
+  @Test
+  public void includedInMergedChange() throws Exception {
+    Result result = createChange();
+    gApi.changes()
+        .id(result.getChangeId())
+        .revision(result.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+
+    assertThat(getIncludedIn(result.getCommit().getId()).branches).containsExactly("master");
+    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
+
+    grantTagPermissions();
+    gApi.projects().name(result.getChange().project().get()).tag("test-tag").create(new TagInput());
+
+    assertThat(getIncludedIn(result.getCommit().getId()).tags).containsExactly("test-tag");
+
+    createBranch(new Branch.NameKey(project.get(), "test-branch"));
+
+    assertThat(getIncludedIn(result.getCommit().getId()).branches)
+        .containsExactly("master", "test-branch");
+  }
+
+  @Test
+  public void cherryPickCommitWithoutChangeId() throws Exception {
+    // This test is a little superfluous, since the current cherry-pick code ignores
+    // the commit message of the to-be-cherry-picked change, using the one in
+    // CherryPickInput instead.
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.message = "it goes to foo branch";
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    RevCommit revCommit = createNewCommitWithoutChangeId("refs/heads/master", "a.txt", "content");
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    CommitInfo commitInfo = revInfo.commit;
+    assertThat(commitInfo.message)
+        .isEqualTo(input.message + "\n\nChange-Id: " + changeInfo.changeId + "\n");
+  }
+
+  @Test
+  public void cherryPickCommitWithChangeId() throws Exception {
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+
+    RevCommit revCommit = createChange().getCommit();
+    List<String> footers = revCommit.getFooterLines("Change-Id");
+    assertThat(footers).hasSize(1);
+    String changeId = footers.get(0);
+
+    input.message = "it goes to foo branch\n\nChange-Id: " + changeId;
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
+  }
+
+  private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
+    return gApi.projects().name(project.get()).commit(id.name()).includedIn();
+  }
+
+  private static void assertPerson(GitPerson actual, TestAccount expected) {
+    assertThat(actual.email).isEqualTo(expected.email);
+    assertThat(actual.name).isEqualTo(expected.fullName);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
new file mode 100644
index 0000000..b140a6e
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -0,0 +1,152 @@
+// 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.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+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.DashboardInfo;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+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.RestApiException;
+import com.google.gerrit.server.project.DashboardsCollection;
+import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class DashboardIT extends AbstractDaemonTest {
+  @Before
+  public void setup() throws Exception {
+    allow("refs/meta/dashboards/*", Permission.CREATE, REGISTERED_USERS);
+  }
+
+  @Test
+  public void defaultDashboardDoesNotExist() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    project().defaultDashboard().get();
+  }
+
+  @Test
+  public void dashboardDoesNotExist() throws Exception {
+    exception.expect(ResourceNotFoundException.class);
+    project().dashboard("my:dashboard").get();
+  }
+
+  @Test
+  public void getDashboard() throws Exception {
+    assertThat(dashboards()).isEmpty();
+    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo result = project().dashboard(info.id).get();
+    assertThat(result.id).isEqualTo(info.id);
+    assertThat(result.path).isEqualTo(info.path);
+    assertThat(result.ref).isEqualTo(info.ref);
+    assertThat(result.project).isEqualTo(project.get());
+    assertThat(result.definingProject).isEqualTo(project.get());
+    assertThat(dashboards()).hasSize(1);
+  }
+
+  @Test
+  public void setDefaultDashboard() throws Exception {
+    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    assertThat(info.isDefault).isNull();
+    project().dashboard(info.id).setDefault();
+    assertThat(project().dashboard(info.id).get().isDefault).isTrue();
+    assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
+  }
+
+  @Test
+  public void setDefaultDashboardByProject() throws Exception {
+    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    assertThat(info.isDefault).isNull();
+    project().defaultDashboard(info.id);
+    assertThat(project().dashboard(info.id).get().isDefault).isTrue();
+    assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
+
+    project().removeDefaultDashboard();
+    assertThat(project().dashboard(info.id).get().isDefault).isNull();
+
+    exception.expect(ResourceNotFoundException.class);
+    project().defaultDashboard().get();
+  }
+
+  @Test
+  public void replaceDefaultDashboard() throws Exception {
+    DashboardInfo d1 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
+    DashboardInfo d2 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
+    assertThat(d1.isDefault).isNull();
+    assertThat(d2.isDefault).isNull();
+    project().dashboard(d1.id).setDefault();
+    assertThat(project().dashboard(d1.id).get().isDefault).isTrue();
+    assertThat(project().dashboard(d2.id).get().isDefault).isNull();
+    assertThat(project().defaultDashboard().get().id).isEqualTo(d1.id);
+    project().dashboard(d2.id).setDefault();
+    assertThat(project().defaultDashboard().get().id).isEqualTo(d2.id);
+    assertThat(project().dashboard(d1.id).get().isDefault).isNull();
+    assertThat(project().dashboard(d2.id).get().isDefault).isTrue();
+  }
+
+  @Test
+  public void cannotGetDashboardWithInheritedForNonDefault() throws Exception {
+    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("inherited flag can only be used with default");
+    project().dashboard(info.id).get(true);
+  }
+
+  private List<DashboardInfo> dashboards() throws Exception {
+    return project().dashboards().get();
+  }
+
+  private ProjectApi project() throws RestApiException {
+    return gApi.projects().name(project.get());
+  }
+
+  private DashboardInfo createDashboard(String ref, String path) throws Exception {
+    DashboardInfo info = DashboardsCollection.newDashboardInfo(ref, path);
+    String canonicalRef = DashboardsCollection.normalizeDashboardRef(info.ref);
+    try {
+      project().branch(canonicalRef).create(new BranchInput());
+    } catch (ResourceConflictException e) {
+      // The branch already exists if this method has already been called once.
+      if (!e.getMessage().contains("already exists")) {
+        throw e;
+      }
+    }
+    try (Repository r = repoManager.openRepository(project)) {
+      TestRepository<Repository>.CommitBuilder cb =
+          new TestRepository<>(r).branch(canonicalRef).commit();
+      String content =
+          "[dashboard]\n"
+              + "Description = Test\n"
+              + "foreach = owner:self\n"
+              + "[section \"Mine\"]\n"
+              + "query = is:open";
+      cb.add(info.path, content);
+      RevCommit c = cb.create();
+      project().commit(c.name());
+    }
+    return info;
+  }
+}
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 56bf554..2c80285 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
@@ -30,9 +30,13 @@
 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.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
+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.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -107,12 +111,12 @@
 
   @Test
   public void createBranch() throws Exception {
-    allow(Permission.READ, ANONYMOUS_USERS, "refs/*");
+    allow("refs/*", Permission.READ, ANONYMOUS_USERS);
     gApi.projects().name(project.get()).branch("foo").create(new BranchInput());
   }
 
   @Test
-  public void description() throws Exception {
+  public void descriptionChangeCausesRefUpdate() throws Exception {
     RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
     assertThat(gApi.projects().name(project.get()).description()).isEmpty();
     DescriptionInput in = new DescriptionInput();
@@ -126,7 +130,19 @@
   }
 
   @Test
-  public void submitType() throws Exception {
+  public void descriptionIsDeletedWhenNotSpecified() throws Exception {
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
+    DescriptionInput in = new DescriptionInput();
+    in.description = "new project description";
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
+    in.description = null;
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
+  }
+
+  @Test
+  public void configChangeCausesRefUpdate() throws Exception {
     RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
 
     ConfigInfo info = getConfig();
@@ -144,6 +160,62 @@
   }
 
   @Test
+  public void setConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    ConfigInfo info = gApi.projects().name(project.get()).config(input);
+    assertThat(info.description).isEqualTo(input.description);
+    assertThat(info.useContributorAgreements.configuredValue)
+        .isEqualTo(input.useContributorAgreements);
+    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
+    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
+    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
+    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
+    assertThat(info.submitType).isEqualTo(input.submitType);
+    assertThat(info.state).isEqualTo(input.state);
+  }
+
+  @Test
+  public void setPartialConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    ConfigInfo info = gApi.projects().name(project.get()).config(input);
+
+    ConfigInput partialInput = new ConfigInput();
+    partialInput.useContributorAgreements = InheritableBoolean.FALSE;
+    info = gApi.projects().name(project.get()).config(partialInput);
+
+    assertThat(info.description).isNull();
+    assertThat(info.useContributorAgreements.configuredValue)
+        .isEqualTo(partialInput.useContributorAgreements);
+    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
+    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
+    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
+    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
+    assertThat(info.submitType).isEqualTo(input.submitType);
+    assertThat(info.state).isEqualTo(input.state);
+  }
+
+  @Test
+  public void nonOwnerCannotSetConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("restricted to project owner");
+    gApi.projects().name(project.get()).config(input);
+  }
+
+  @Test
   public void maxObjectSizeIsNotSetByDefault() throws Exception {
     ConfigInfo info = getConfig();
     assertThat(info.maxObjectSizeLimit.value).isNull();
@@ -310,6 +382,58 @@
     setMaxObjectSize("100 foo");
   }
 
+  @Test
+  public void setHead() throws Exception {
+    assertThat(gApi.projects().name(project.get()).head()).isEqualTo("refs/heads/master");
+    gApi.projects().name(project.get()).branch("test1").create(new BranchInput());
+    gApi.projects().name(project.get()).branch("test2").create(new BranchInput());
+    for (String head : new String[] {"test1", "refs/heads/test2"}) {
+      gApi.projects().name(project.get()).head(head);
+      assertThat(gApi.projects().name(project.get()).head()).isEqualTo(RefNames.fullName(head));
+    }
+  }
+
+  @Test
+  public void setHeadToNonexistentBranch() throws Exception {
+    exception.expect(UnprocessableEntityException.class);
+    gApi.projects().name(project.get()).head("does-not-exist");
+  }
+
+  @Test
+  public void setHeadToSameBranch() throws Exception {
+    gApi.projects().name(project.get()).branch("test").create(new BranchInput());
+    for (String head : new String[] {"test", "refs/heads/test"}) {
+      gApi.projects().name(project.get()).head(head);
+      assertThat(gApi.projects().name(project.get()).head()).isEqualTo(RefNames.fullName(head));
+    }
+  }
+
+  @Test
+  public void setHeadNotAllowed() throws Exception {
+    gApi.projects().name(project.get()).branch("test").create(new BranchInput());
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("restricted to project owner");
+    gApi.projects().name(project.get()).head("test");
+  }
+
+  private ConfigInput createTestConfigInput() {
+    ConfigInput input = new ConfigInput();
+    input.description = "some description";
+    input.useContributorAgreements = InheritableBoolean.TRUE;
+    input.useContentMerge = InheritableBoolean.TRUE;
+    input.useSignedOffBy = InheritableBoolean.TRUE;
+    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
+    input.requireChangeId = InheritableBoolean.TRUE;
+    input.rejectImplicitMerges = InheritableBoolean.TRUE;
+    input.enableReviewerByEmail = InheritableBoolean.TRUE;
+    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
+    input.maxObjectSizeLimit = "5m";
+    input.submitType = SubmitType.CHERRY_PICK;
+    input.state = ProjectState.HIDDEN;
+    return input;
+  }
+
   private ConfigInfo setConfig(Project.NameKey name, ConfigInput input) throws Exception {
     return gApi.projects().name(name.get()).config(input);
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
new file mode 100644
index 0000000..2fa55af
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -0,0 +1,1610 @@
+// 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.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.common.DiffInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.FileInfoSubject.assertThat;
+import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.changes.FileApi;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.testutil.ConfigSuite;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+import javax.imageio.ImageIO;
+import org.eclipse.jgit.lib.Config;
+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;
+
+public class RevisionDiffIT extends AbstractDaemonTest {
+  // @RunWith(Parameterized.class) can't be used as AbstractDaemonTest is annotated with another
+  // runner. Using different configs is a workaround to achieve the same.
+  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+  private static final String CURRENT = "current";
+  private static final String FILE_NAME = "some_file.txt";
+  private static final String FILE_NAME2 = "another_file.txt";
+  private static final String FILE_CONTENT =
+      IntStream.rangeClosed(1, 100)
+          .mapToObj(number -> String.format("Line %d\n", number))
+          .collect(joining());
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+
+  private boolean intraline;
+  private ObjectId commit1;
+  private String changeId;
+  private String initialPatchSetId;
+
+  @ConfigSuite.Config
+  public static Config intralineConfig() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
+    return config;
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    // Reduce flakiness of tests. (If tests aren't fast enough, we would use a fall-back
+    // computation, which might yield different results.)
+    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+    baseConfig.setString("cache", "diff_intraline", "timeout", "1 minute");
+
+    intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
+
+    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    commit1 =
+        addCommit(headCommit, ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
+
+    Result result = createEmptyChange();
+    changeId = result.getChangeId();
+    initialPatchSetId = result.getPatchSetId().getId();
+  }
+
+  @Test
+  public void diff() throws Exception {
+    // The assertions assume that intraline is false.
+    assume().that(intraline).isFalse();
+
+    String fileName = "a_new_file.txt";
+    String fileContent = "First line\nSecond line\n";
+    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+    assertDiffForNewFile(result, fileName, fileContent);
+    assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
+  }
+
+  @Test
+  public void diffDeletedFile() throws Exception {
+    gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+
+    DiffInfo diff = getDiffRequest(changeId, CURRENT, FILE_NAME).get();
+    assertThat(diff.metaA.lines).isEqualTo(100);
+    assertThat(diff.metaB).isNull();
+  }
+
+  @Test
+  public void addedFileIsIncludedInDiff() throws Exception {
+    String newFilePath = "a_new_file.txt";
+    String newFileContent = "arbitrary content";
+    gApi.changes().id(changeId).edit().modifyFile(newFilePath, RawInputUtil.create(newFileContent));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath);
+  }
+
+  @Test
+  public void renamedFileIsIncludedInDiff() throws Exception {
+    String newFilePath = "a_new_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath);
+  }
+
+  @Test
+  public void copiedFileTreatedAsAddedFileInDiff() throws Exception {
+    String copyFilePath = "copy_of_some_file.txt";
+    gApi.changes().id(changeId).edit().modifyFile(copyFilePath, RawInputUtil.create(FILE_CONTENT));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, copyFilePath);
+    // If this ever changes, please add tests which cover copied files.
+    assertThat(changedFiles.get(copyFilePath)).status().isEqualTo('A');
+    assertThat(changedFiles.get(copyFilePath)).linesInserted().isEqualTo(100);
+    assertThat(changedFiles.get(copyFilePath)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void addedBinaryFileIsIncludedInDiff() throws Exception {
+    String imageFileName = "an_image.png";
+    byte[] imageBytes = createRgbImage(255, 0, 0);
+    gApi.changes().id(changeId).edit().modifyFile(imageFileName, RawInputUtil.create(imageBytes));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, imageFileName);
+  }
+
+  @Test
+  public void modifiedBinaryFileIsIncludedInDiff() throws Exception {
+    String imageFileName = "an_image.png";
+    byte[] imageBytes1 = createRgbImage(255, 100, 0);
+    ObjectId commit2 = addCommit(commit1, imageFileName, imageBytes1);
+
+    rebaseChangeOn(changeId, commit2);
+    byte[] imageBytes2 = createRgbImage(0, 100, 255);
+    gApi.changes().id(changeId).edit().modifyFile(imageFileName, RawInputUtil.create(imageBytes2));
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, imageFileName);
+  }
+
+  @Test
+  public void diffOnMergeCommitChange() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+
+    DiffInfo diff;
+
+    // automerge
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").get();
+    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").get();
+    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    // parent 1
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(1).get();
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    // parent 2
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(2).get();
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+  }
+
+  @Test
+  public void addedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
+    ObjectId commit2 = addCommit(commit1, "file_added_in_another_commit.txt", "Some file content");
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void removedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
+    ObjectId commit2 = addCommitRemovingFiles(commit1, FILE_NAME2);
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
+    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME2, "a_new_file_name.txt");
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenEquallyModifiedInBoth()
+      throws Exception {
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("1st line\n", "First line\n");
+    addModifiedPatchSet(changeId, FILE_NAME2, contentModification);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the modification to be able to rebase.
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, fileContent -> fileContent.replace("First line\n", "1st line\n"));
+
+    String renamedFileName = "renamed_file.txt";
+    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME2, renamedFileName);
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(changeId, renamedFileName, contentModification);
+    addModifiedPatchSet(changeId, FILE_NAME, "Another line\n"::concat);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenModifiedDuringRebase()
+      throws Exception {
+    String renamedFilePath = "renamed_some_file.txt";
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFilePath);
+
+    rebaseChangeOn(changeId, commit3);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void fileRenamedDuringRebaseSameAsInPatchSetIsIgnored() throws Exception {
+    String renamedFileName = "renamed_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(renamedFileName, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME, renamedFileName);
+    rebaseChangeOn(changeId, commit2);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void fileWithRebaseHunksRenamedDuringRebaseSameAsInPatchSetIsIgnored() throws Exception {
+    String renamedFileName = "renamed_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(renamedFileName, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 10\n", "Line ten\n"));
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFileName);
+    rebaseChangeOn(changeId, commit3);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void filesNotTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 10\n", "Line ten\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME2, "a_new_file_name.txt");
+
+    rebaseChangeOn(changeId, commit3);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void filesTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, fileContent -> fileContent.replace("1st line\n", "First line\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    // Revert the modification to allow rebasing.
+    addModifiedPatchSet(
+        changeId, FILE_NAME2, fileContent -> fileContent.replace("First line\n", "1st line\n"));
+
+    String newFileContent = FILE_CONTENT.replace("Line 10\n", "Line ten\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+    String newFilePath = "a_new_file_name.txt";
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME2, newFilePath);
+
+    rebaseChangeOn(changeId, commit3);
+    // Apply the modification again to bring the file into the same state as for the previous
+    // patch set.
+    addModifiedPatchSet(
+        changeId, newFilePath, fileContent -> fileContent.replace("1st line\n", "First line\n"));
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG);
+  }
+
+  @Test
+  public void rebaseHunksAtStartOfFileAreIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo).content().element(0).isDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(2).isDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(4).isNotDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(50);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunksAtEndOfFileAreIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT
+            .replace("Line 60\n", "Line sixty\n")
+            .replace("Line 100\n", "Line one hundred\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(49);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
+    assertThat(diffInfo).content().element(3).isDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(5).isDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunksInBetweenRegularHunksAreIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 40\n", "Line forty\n").replace("Line 45\n", "Line forty five\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent
+                .replace("Line 1\n", "Line one\n")
+                .replace("Line 100\n", "Line one hundred\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(2).isDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 45");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty five");
+    assertThat(diffInfo).content().element(4).isDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(54);
+    assertThat(diffInfo).content().element(6).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(6).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(6).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void rebaseHunkIsIdentifiedWhenMovedDownInPreviousPatchSet() throws Exception {
+    // Move the code down by introducing additional lines (pure insert + enlarging replacement) in
+    // the previous patch set.
+    Function<String, String> contentModification1 =
+        fileContent ->
+            "Line zero\n" + fileContent.replace("Line 10\n", "Line ten\nLine ten and a half\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification1);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification2 =
+        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification2);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(41);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkIsIdentifiedWhenMovedDownInLatestPatchSet() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    // Move the code down by introducing additional lines (pure insert + enlarging replacement) in
+    // the latest patch set.
+    Function<String, String> contentModification =
+        fileContent ->
+            "Line zero\n" + fileContent.replace("Line 10\n", "Line ten\nLine ten and a half\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().isNull();
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line zero");
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10");
+    assertThat(diffInfo)
+        .content()
+        .element(2)
+        .linesOfB()
+        .containsExactly("Line ten", "Line ten and a half");
+    assertThat(diffInfo).content().element(2).isNotDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(4).isDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkIsIdentifiedWhenMovedUpInPreviousPatchSet() throws Exception {
+    // Move the code up by removing lines (pure deletion + shrinking replacement) in the previous
+    // patch set.
+    Function<String, String> contentModification1 =
+        fileContent ->
+            fileContent.replace("Line 1\n", "").replace("Line 10\nLine 11\n", "Line ten\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification1);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification2 =
+        fileContent -> fileContent.replace("Line 100\n", "Line one hundred\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification2);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(37);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkIsIdentifiedWhenMovedUpInLatestPatchSet() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    // Move the code up by removing lines (pure deletion + shrinking replacement) in the latest
+    // patch set.
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent.replace("Line 1\n", "").replace("Line 10\nLine 11\n", "Line ten\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().isNull();
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10", "Line 11");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line ten");
+    assertThat(diffInfo).content().element(2).isNotDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(28);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
+    assertThat(diffInfo).content().element(4).isDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void modifiedRebaseHunkWithSameRegionConsideredAsRegularHunk() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 40\n", "Line forty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line forty\n", "Line modified after rebase\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line modified after rebase");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkOverlappingAtBeginningConsideredAsRegularHunk() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent
+                .replace("Line 39\n", "Line thirty nine\n")
+                .replace("Line forty one\n", "Line 41\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 39", "Line 40");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line thirty nine", "Line forty");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void rebaseHunkOverlappingAtEndConsideredAsRegularHunk() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent
+                .replace("Line forty\n", "Line 40\n")
+                .replace("Line 42\n", "Line forty two\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line forty one", "Line forty two");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(58);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void rebaseHunkModifiedInsideConsideredAsRegularHunk() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace(
+            "Line 39\nLine 40\nLine 41\n", "Line thirty nine\nLine forty\nLine forty one\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line forty\n", "A different line forty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfA()
+        .containsExactly("Line 39", "Line 40", "Line 41");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line thirty nine", "A different line forty", "Line forty one");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void rebaseHunkAfterLineNumberChangingOverlappingHunksIsIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT
+            .replace("Line 40\nLine 41\n", "Line forty\nLine forty one\n")
+            .replace("Line 60\n", "Line sixty\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent
+                .replace("Line forty\n", "Line 40\n")
+                .replace("Line 42\n", "Line forty two\nLine forty two and a half\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line forty one", "Line forty two", "Line forty two and a half");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(17);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
+    assertThat(diffInfo).content().element(3).isDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(40);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void rebaseHunksOneLineApartFromRegularHunkAreIdentified() throws Exception {
+    String newFileContent =
+        FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 3\n", "Line three\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo).content().element(0).isDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 3");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line three");
+    assertThat(diffInfo).content().element(2).isNotDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(4).isDueToRebase();
+    assertThat(diffInfo).content().element(5).commonLines().hasSize(95);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunksDirectlyTouchingHunksOfPatchSetsNotModifiedBetweenThemAreIdentified()
+      throws Exception {
+    // Add to hunks in a patch set and remove them in a further patch set to allow rebasing.
+    Function<String, String> contentModification =
+        fileContent ->
+            fileContent.replace("Line 1\n", "Line one\n").replace("Line 3\n", "Line three\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    Function<String, String> reverseContentModification =
+        fileContent ->
+            fileContent.replace("Line one\n", "Line 1\n").replace("Line three\n", "Line 3\n");
+    addModifiedPatchSet(changeId, FILE_NAME, reverseContentModification);
+
+    String newFileContent = FILE_CONTENT.replace("Line 2\n", "Line two\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+    rebaseChangeOn(changeId, commit2);
+
+    // Add the hunks again and modify another line so that we get a diff for the file.
+    // (Files with only edits due to rebase are filtered out.)
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        contentModification.andThen(fileContent -> fileContent.replace("Line 10\n", "Line ten\n")));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(7);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 10");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line ten");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(90);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void multipleRebaseEditsMixedWithRegularEditsCanBeIdentified() throws Exception {
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        fileContent -> fileContent.replace("Line 7\n", "Line seven\n").replace("Line 24\n", ""));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    ObjectId commit2 =
+        addCommit(
+            commit1,
+            FILE_NAME,
+            FILE_CONTENT
+                .replace("Line 2\n", "Line two\n")
+                .replace("Line 18\nLine 19\n", "Line eighteen\nLine nineteen\n")
+                .replace("Line 50\n", "Line fifty\n"));
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        fileContent ->
+            fileContent
+                .replace("Line seven\n", "Line 7\n")
+                .replace("Line 9\n", "Line nine\n")
+                .replace("Line 60\n", "Line sixty\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line seven");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 7");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 9");
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line nine");
+    assertThat(diffInfo).content().element(5).isNotDueToRebase();
+    assertThat(diffInfo).content().element(6).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 18", "Line 19");
+    assertThat(diffInfo)
+        .content()
+        .element(7)
+        .linesOfB()
+        .containsExactly("Line eighteen", "Line nineteen");
+    assertThat(diffInfo).content().element(7).isDueToRebase();
+    assertThat(diffInfo).content().element(8).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(9).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(9).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(9).isDueToRebase();
+    assertThat(diffInfo).content().element(10).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(11).linesOfA().containsExactly("Line 60");
+    assertThat(diffInfo).content().element(11).linesOfB().containsExactly("Line sixty");
+    assertThat(diffInfo).content().element(11).isNotDueToRebase();
+    assertThat(diffInfo).content().element(12).commonLines().hasSize(40);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(3);
+  }
+
+  @Test
+  public void deletedFileDuringRebaseConsideredAsRegularHunkWhenModifiedInDiff() throws Exception {
+    // Modify the file and revert the modifications to allow rebasing.
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId, FILE_NAME, fileContent -> fileContent.replace("Line fifty\n", "Line 50\n"));
+
+    ObjectId commit2 = addCommitRemovingFiles(commit1, FILE_NAME);
+
+    rebaseChangeOn(changeId, commit2);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).changeType().isEqualTo(ChangeType.DELETED);
+    assertThat(diffInfo).content().element(0).linesOfA().hasSize(100);
+    assertThat(diffInfo).content().element(0).linesOfB().isNull();
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isNull();
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(100);
+  }
+
+  @Test
+  public void addedFileDuringRebaseConsideredAsRegularHunkWhenModifiedInDiff() throws Exception {
+    String newFilePath = "a_new_file.txt";
+    ObjectId commit2 = addCommit(commit1, newFilePath, "1st line\n2nd line\n3rd line\n");
+
+    rebaseChangeOn(changeId, commit2);
+    addModifiedPatchSet(
+        changeId, newFilePath, fileContent -> fileContent.replace("1st line\n", "First line\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, newFilePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).changeType().isEqualTo(ChangeType.ADDED);
+    assertThat(diffInfo).content().element(0).linesOfA().isNull();
+    assertThat(diffInfo).content().element(0).linesOfB().hasSize(3);
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(newFilePath)).linesInserted().isEqualTo(3);
+    assertThat(changedFiles.get(newFilePath)).linesDeleted().isNull();
+  }
+
+  @Test
+  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedDuringRebase() throws Exception {
+    String renamedFilePath = "renamed_some_file.txt";
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 1\n", "Line one\n"));
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, renamedFilePath);
+
+    rebaseChangeOn(changeId, commit3);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, renamedFilePath, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo).content().element(0).isDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(2).isNotDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.get(renamedFilePath)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(renamedFilePath)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedInPatchSets() throws Exception {
+    String renamedFilePath = "renamed_some_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFilePath);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(renamedFilePath, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+
+    rebaseChangeOn(changeId, commit2);
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFilePath);
+    gApi.changes().id(changeId).edit().publish();
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, renamedFilePath, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(50);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(renamedFilePath)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(renamedFilePath)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedBetweenPatchSets()
+      throws Exception {
+    String newFilePath1 = "renamed_some_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(newFilePath1, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+
+    rebaseChangeOn(changeId, commit2);
+    String newFilePath2 = "renamed_some_file_to_something_else.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath2);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath2);
+    assertThat(changedFiles.get(newFilePath2)).linesInserted().isNull();
+    assertThat(changedFiles.get(newFilePath2)).linesDeleted().isNull();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, newFilePath2).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+  }
+
+  @Test
+  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedForRebaseAndForPatchSets()
+      throws Exception {
+    String newFilePath1 = "renamed_some_file.txt";
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Revert the renaming to be able to rebase.
+    gApi.changes().id(changeId).edit().renameFile(newFilePath1, FILE_NAME);
+    gApi.changes().id(changeId).edit().publish();
+
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+    String newFilePath2 = "renamed_some_file_during_rebase.txt";
+    ObjectId commit3 = addCommitRenamingFile(commit2, FILE_NAME, newFilePath2);
+
+    rebaseChangeOn(changeId, commit3);
+    String newFilePath3 = "renamed_some_file_to_something_else.txt";
+    gApi.changes().id(changeId).edit().renameFile(newFilePath2, newFilePath3);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, newFilePath3);
+    assertThat(changedFiles.get(newFilePath3)).linesInserted().isNull();
+    assertThat(changedFiles.get(newFilePath3)).linesDeleted().isNull();
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, newFilePath3).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+  }
+
+  @Test
+  public void copiedAndRenamedFilesWithOnlyRebaseHunksAreIdentified() throws Exception {
+    String newFileContent = FILE_CONTENT.replace("Line 5\n", "Line five\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    // Copies are only identified by JGit when paired with renaming.
+    String copyFileName = "copy_of_some_file.txt";
+    String renamedFileName = "renamed_some_file.txt";
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyFile(copyFileName, RawInputUtil.create(newFileContent));
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(initialPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, copyFileName, renamedFileName);
+
+    DiffInfo renamedFileDiffInfo =
+        getDiffRequest(changeId, CURRENT, renamedFileName).withBase(initialPatchSetId).get();
+    assertThat(renamedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(renamedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(renamedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(renamedFileDiffInfo).content().element(1).isDueToRebase();
+    assertThat(renamedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+
+    DiffInfo copiedFileDiffInfo =
+        getDiffRequest(changeId, CURRENT, copyFileName).withBase(initialPatchSetId).get();
+    assertThat(copiedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(copiedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
+    assertThat(copiedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
+    assertThat(copiedFileDiffInfo).content().element(1).isDueToRebase();
+    assertThat(copiedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+  }
+
+  /*
+   *                change PS B
+   *                   |
+   * change PS A    commit4
+   *    |              |
+   * commit2        commit3
+   *    |             /
+   * commit1 --------
+   */
+  @Test
+  public void rebaseHunksWhenRebasingOnAnotherChangeOrPatchSetAreIdentified() throws Exception {
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+    rebaseChangeOn(changeId, commit2);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String commit3FileContent = FILE_CONTENT.replace("Line 35\n", "Line thirty five\n");
+    ObjectId commit3 = addCommit(commit1, FILE_NAME, commit3FileContent);
+    ObjectId commit4 =
+        addCommit(commit3, FILE_NAME, commit3FileContent.replace("Line 60\n", "Line sixty\n"));
+
+    rebaseChangeOn(changeId, commit4);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 20");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line twenty");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 35");
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line thirty five");
+    assertThat(diffInfo).content().element(5).isDueToRebase();
+    assertThat(diffInfo).content().element(6).commonLines().hasSize(24);
+    assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 60");
+    assertThat(diffInfo).content().element(7).linesOfB().containsExactly("Line sixty");
+    assertThat(diffInfo).content().element(7).isDueToRebase();
+    assertThat(diffInfo).content().element(8).commonLines().hasSize(40);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  /*
+   *                change PS B
+   *                   |
+   * change PS A    commit4
+   *    |              |
+   * commit2        commit3
+   *    |             /
+   * commit1 --------
+   */
+  @Test
+  public void unrelatedFileWhenRebasingOnAnotherChangeOrPatchSetIsIgnored() throws Exception {
+    ObjectId commit2 =
+        addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 5\n", "Line five\n"));
+    rebaseChangeOn(changeId, commit2);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    ObjectId commit3 =
+        addCommit(commit1, FILE_NAME2, FILE_CONTENT2.replace("2nd line\n", "Second line\n"));
+    ObjectId commit4 =
+        addCommit(commit3, FILE_NAME, FILE_CONTENT.replace("Line 60\n", "Line sixty\n"));
+
+    rebaseChangeOn(changeId, commit4);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, FILE_NAME);
+  }
+
+  @Test
+  public void rebaseHunksWhenReversingPatchSetOrderAreIdentified() throws Exception {
+    ObjectId commit2 =
+        addCommit(
+            commit1,
+            FILE_NAME,
+            FILE_CONTENT.replace("Line 5\n", "Line five\n").replace("Line 35\n", ""));
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 20\n", "Line twenty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    String currentPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, initialPatchSetId, FILE_NAME).withBase(currentPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line twenty");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 20");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(5).linesOfA().isNull();
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line 35");
+    assertThat(diffInfo).content().element(5).isDueToRebase();
+    assertThat(diffInfo).content().element(6).commonLines().hasSize(65);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).revision(initialPatchSetId).files(currentPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void intralineEditsInNonRebaseHunksAreIdentified() throws Exception {
+    assume().that(intraline).isTrue();
+
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 1\n", "Line one\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .intralineEditsOfA()
+        .containsExactly(ImmutableList.of(5, 1));
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .intralineEditsOfB()
+        .containsExactly(ImmutableList.of(5, 3));
+    assertThat(diffInfo).content().element(0).isNotDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(99);
+  }
+
+  @Test
+  public void intralineEditsInRebaseHunksAreIdentified() throws Exception {
+    assume().that(intraline).isTrue();
+
+    String newFileContent = FILE_CONTENT.replace("Line 1\n", "Line one\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, newFileContent);
+
+    rebaseChangeOn(changeId, commit2);
+    Function<String, String> contentModification =
+        fileContent -> fileContent.replace("Line 50\n", "Line fifty\n");
+    addModifiedPatchSet(changeId, FILE_NAME, contentModification);
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .intralineEditsOfA()
+        .containsExactly(ImmutableList.of(5, 1));
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .intralineEditsOfB()
+        .containsExactly(ImmutableList.of(5, 3));
+    assertThat(diffInfo).content().element(0).isDueToRebase();
+    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
+    assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
+    assertThat(diffInfo).content().element(2).isNotDueToRebase();
+    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
+  }
+
+  @Test
+  public void closeNonRebaseHunksAreCombinedForIntralineOptimizations() throws Exception {
+    assume().that(intraline).isTrue();
+
+    String fileContent = FILE_CONTENT.replace("Line 5\n", "{\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, fileContent);
+    rebaseChangeOn(changeId, commit2);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        content -> content.replace("Line 4\n", "Line four\n").replace("Line 6\n", "Line six\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line four", "{", "Line six");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(94);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    // Lines which weren't modified but are included in a hunk due to optimization don't count for
+    // the number of inserted/deleted lines.
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  @Test
+  public void closeRebaseHunksAreNotCombinedForIntralineOptimizations() throws Exception {
+    assume().that(intraline).isTrue();
+
+    String fileContent = FILE_CONTENT.replace("Line 5\n", "{\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, fileContent);
+    rebaseChangeOn(changeId, commit2);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String newFileContent =
+        fileContent.replace("Line 4\n", "Line four\n").replace("Line 6\n", "Line six\n");
+    ObjectId commit3 = addCommit(commit1, FILE_NAME, newFileContent);
+    rebaseChangeOn(changeId, commit3);
+
+    addModifiedPatchSet(
+        changeId, FILE_NAME, content -> content.replace("Line 20\n", "Line twenty\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
+    assertThat(diffInfo).content().element(3).isDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(13);
+    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 20");
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line twenty");
+    assertThat(diffInfo).content().element(5).isNotDueToRebase();
+    assertThat(diffInfo).content().element(6).commonLines().hasSize(80);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void closeRebaseAndNonRebaseHunksAreNotCombinedForIntralineOptimizations()
+      throws Exception {
+    assume().that(intraline).isTrue();
+
+    String fileContent = FILE_CONTENT.replace("Line 5\n", "{\n").replace("Line 7\n", "{\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, fileContent);
+    rebaseChangeOn(changeId, commit2);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String newFileContent =
+        fileContent.replace("Line 4\n", "Line four\n").replace("Line 8\n", "Line eight\n");
+    ObjectId commit3 = addCommit(commit1, FILE_NAME, newFileContent);
+    rebaseChangeOn(changeId, commit3);
+
+    addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 6\n", "Line six\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
+    assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
+    assertThat(diffInfo).content().element(1).isDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
+    assertThat(diffInfo).content().element(3).isNotDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 8");
+    assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line eight");
+    assertThat(diffInfo).content().element(5).isDueToRebase();
+    assertThat(diffInfo).content().element(6).commonLines().hasSize(92);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(1);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(1);
+  }
+
+  @Test
+  public void closeNonRebaseHunksNextToRebaseHunksAreCombinedForIntralineOptimizations()
+      throws Exception {
+    assume().that(intraline).isTrue();
+
+    String fileContent = FILE_CONTENT.replace("Line 5\n", "{\n").replace("Line 7\n", "{\n");
+    ObjectId commit2 = addCommit(commit1, FILE_NAME, fileContent);
+    rebaseChangeOn(changeId, commit2);
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+
+    String newFileContent = fileContent.replace("Line 8\n", "Line eight!\n");
+    ObjectId commit3 = addCommit(commit1, FILE_NAME, newFileContent);
+    rebaseChangeOn(changeId, commit3);
+
+    addModifiedPatchSet(
+        changeId,
+        FILE_NAME,
+        content -> content.replace("Line 4\n", "Line four\n").replace("Line 6\n", "Line six\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Line four", "{", "Line six");
+    assertThat(diffInfo).content().element(1).isNotDueToRebase();
+    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 8");
+    assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line eight!");
+    assertThat(diffInfo).content().element(3).isDueToRebase();
+    assertThat(diffInfo).content().element(4).commonLines().hasSize(92);
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(changeId).current().files(previousPatchSetId);
+    assertThat(changedFiles.get(FILE_NAME)).linesInserted().isEqualTo(2);
+    assertThat(changedFiles.get(FILE_NAME)).linesDeleted().isEqualTo(2);
+  }
+
+  private void assertDiffForNewFile(
+      PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
+    DiffInfo diff =
+        gApi.changes()
+            .id(pushResult.getChangeId())
+            .revision(pushResult.getCommit().name())
+            .file(path)
+            .diff();
+
+    List<String> headers = new ArrayList<>();
+    if (path.equals(COMMIT_MSG)) {
+      RevCommit c = pushResult.getCommit();
+
+      RevCommit parentCommit = c.getParents()[0];
+      String parentCommitId =
+          testRepo.getRevWalk().getObjectReader().abbreviate(parentCommit.getId(), 8).name();
+      headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
+
+      SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
+      PersonIdent author = c.getAuthorIdent();
+      dtfmt.setTimeZone(author.getTimeZone());
+      headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
+      headers.add("AuthorDate: " + dtfmt.format(author.getWhen().getTime()));
+
+      PersonIdent committer = c.getCommitterIdent();
+      dtfmt.setTimeZone(committer.getTimeZone());
+      headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
+      headers.add("CommitDate: " + dtfmt.format(committer.getWhen().getTime()));
+      headers.add("");
+    }
+
+    if (!headers.isEmpty()) {
+      String header = Joiner.on("\n").join(headers);
+      expectedContentSideB = header + "\n" + expectedContentSideB;
+    }
+
+    assertDiffForNewFile(diff, pushResult.getCommit(), path, expectedContentSideB);
+  }
+
+  private void rebaseChangeOn(String changeId, ObjectId newParent) throws Exception {
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.base = newParent.getName();
+    gApi.changes().id(changeId).current().rebase(rebaseInput);
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, String filePath, String fileContent)
+      throws Exception {
+    ImmutableMap<String, String> files = ImmutableMap.of(filePath, fileContent);
+    return addCommit(parentCommit, files);
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, ImmutableMap<String, String> files)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "Adjust files of repo", files);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    return result.getCommit();
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, String filePath, byte[] fileContent)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit.Result result = createEmptyChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    return ObjectId.fromString(currentRevision);
+  }
+
+  private ObjectId addCommitRemovingFiles(ObjectId parentCommit, String... removedFilePaths)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    Map<String, String> files =
+        Arrays.stream(removedFilePaths)
+            .collect(toMap(Function.identity(), path -> "Irrelevant content"));
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "Remove files from repo", files);
+    PushOneCommit.Result result = push.rm("refs/for/master");
+    return result.getCommit();
+  }
+
+  private ObjectId addCommitRenamingFile(
+      ObjectId parentCommit, String oldFilePath, String newFilePath) throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit.Result result = createEmptyChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).edit().renameFile(oldFilePath, newFilePath);
+    gApi.changes().id(changeId).edit().publish();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    return ObjectId.fromString(currentRevision);
+  }
+
+  private Result createEmptyChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(db, admin.getIdent(), testRepo, "Test change", ImmutableMap.of());
+    return push.to("refs/for/master");
+  }
+
+  private void addModifiedPatchSet(
+      String changeId, String filePath, Function<String, String> contentModification)
+      throws Exception {
+    try (BinaryResult content = gApi.changes().id(changeId).current().file(filePath).content()) {
+      String newContent = contentModification.apply(content.asString());
+      gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(newContent));
+    }
+    gApi.changes().id(changeId).edit().publish();
+  }
+
+  private static byte[] createRgbImage(int red, int green, int blue) throws IOException {
+    BufferedImage bufferedImage = new BufferedImage(10, 20, BufferedImage.TYPE_INT_RGB);
+    for (int x = 0; x < bufferedImage.getWidth(); x++) {
+      for (int y = 0; y < bufferedImage.getHeight(); y++) {
+        int rgb = (red << 16) + (green << 8) + blue;
+        bufferedImage.setRGB(x, y, rgb);
+      }
+    }
+
+    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+    ImageIO.write(bufferedImage, "png", byteArrayOutputStream);
+    return byteArrayOutputStream.toByteArray();
+  }
+
+  private FileApi.DiffRequest getDiffRequest(String changeId, String revisionId, String fileName)
+      throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .revision(revisionId)
+        .file(fileName)
+        .diffRequest()
+        .withIntraline(intraline);
+  }
+}
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 3f7a7e5..5ecb028 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
@@ -15,56 +15,70 @@
 package com.google.gerrit.acceptance.api.revision;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 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.PATCH_FILE_ONLY;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testutil.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.junit.Assert.fail;
 
-import com.google.common.base.Joiner;
 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.Iterators;
+import com.google.common.collect.ListMultimap;
 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.acceptance.TestProjectInput;
+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;
 import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
 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.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+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.BinaryResult;
 import com.google.gerrit.extensions.restapi.ETagView;
 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.UnprocessableEntityException;
+import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -74,12 +88,11 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
+import java.sql.Timestamp;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
-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.List;
@@ -96,6 +109,7 @@
 public class RevisionIT extends AbstractDaemonTest {
 
   @Inject private GetRevisionActions getRevisionActions;
+  @Inject private DynamicSet<PatchSetWebLink> patchSetLinks;
 
   @Test
   public void reviewTriplet() throws Exception {
@@ -148,8 +162,7 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
-    assertPermitted(
-        gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS)), "Code-Review", 1, 2);
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 1, 2);
 
     // Repeating the current label is allowed. Does not flip the postSubmit bit
     // due to deduplication codepath.
@@ -176,7 +189,7 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(2);
     assertThat(approval.postSubmit).isTrue();
-    assertPermitted(gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS)), "Code-Review", 2);
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 2);
 
     // Decreasing to previous post-submit vote is still not allowed.
     try {
@@ -209,7 +222,7 @@
         get(changeId, DETAILED_LABELS).labels.get("Code-Review").all.stream()
             .filter(a -> a._accountId == user.id.get())
             .findFirst();
-    assertThat(crUser.isPresent()).isTrue();
+    assertThat(crUser).isPresent();
     assertThat(crUser.get().value).isEqualTo(0);
 
     revision(r).submit();
@@ -221,8 +234,7 @@
     revision(r).review(in);
 
     ApprovalInfo cr =
-        gApi.changes().id(changeId).get(EnumSet.of(ListChangesOption.DETAILED_LABELS)).labels
-            .get("Code-Review").all.stream()
+        gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
             .filter(a -> a._accountId == user.getId().get())
             .findFirst()
             .get();
@@ -273,9 +285,12 @@
   }
 
   @Test
-  public void deleteDraft() throws Exception {
-    PushOneCommit.Result r = createDraft();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).delete();
+  public void voteNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("is restricted");
+    gApi.changes().id(r.getChange().getId().get()).current().review(ReviewInput.approve());
   }
 
   @Test
@@ -290,8 +305,9 @@
     assertThat(orig.get().messages).hasSize(1);
     ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
 
-    Collection<ChangeMessageInfo> messages =
-        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get().messages;
+    ChangeInfo changeInfoWithDetails =
+        gApi.changes().id(project.get() + "~master~" + r.getChangeId()).get();
+    Collection<ChangeMessageInfo> messages = changeInfoWithDetails.messages;
     assertThat(messages).hasSize(2);
 
     String cherryPickedRevision = cherry.get().currentRevision;
@@ -305,8 +321,10 @@
     origIt.next();
     assertThat(origIt.next().message).isEqualTo(expectedMessage);
 
-    assertThat(cherry.get().messages).hasSize(1);
-    Iterator<ChangeMessageInfo> cherryIt = cherry.get().messages.iterator();
+    ChangeInfo cherryPickChangeInfoWithDetails = cherry.get();
+    assertThat(cherryPickChangeInfoWithDetails.workInProgress).isNull();
+    assertThat(cherryPickChangeInfoWithDetails.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> cherryIt = cherryPickChangeInfoWithDetails.messages.iterator();
     expectedMessage = "Patch Set 1: Cherry Picked from branch master.";
     assertThat(cherryIt.next().message).isEqualTo(expectedMessage);
 
@@ -317,6 +335,28 @@
   }
 
   @Test
+  public void cherryPickSetChangeId() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    String id = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbe3f";
+    in.message = "it goes to foo branch\n\nChange-Id: " + id;
+
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    assertThat(orig.get().messages).hasSize(1);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+
+    ChangeInfo changeInfo = cherry.get();
+
+    // The cherry-pick honors the ChangeId specified in the input message:
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).endsWith(id + "\n");
+  }
+
+  @Test
   public void cherryPickwithNoTopic() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     CherryPickInput in = new CherryPickInput();
@@ -332,6 +372,19 @@
   }
 
   @Test
+  public void cherryPickWorkInProgressChange() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%wip");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "cherry pick message";
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().workInProgress).isTrue();
+  }
+
+  @Test
   public void cherryPickToSameBranch() throws Exception {
     PushOneCommit.Result r = createChange();
     CherryPickInput in = new CherryPickInput();
@@ -580,6 +633,202 @@
   }
 
   @Test
+  public void cherryPickNotify() throws Exception {
+    createBranch(new Branch.NameKey(project, "branch-1"));
+    createBranch(new Branch.NameKey(project, "branch-2"));
+    createBranch(new Branch.NameKey(project, "branch-3"));
+
+    // Creates a change for 'admin'.
+    PushOneCommit.Result result = createChange();
+    String changeId = project.get() + "~master~" + result.getChangeId();
+
+    // 'user' cherry-picks the change to a new branch, the source change's author/committer('admin')
+    // will be added as a reviewer of the newly created change.
+    setApiUser(user);
+    CherryPickInput input = new CherryPickInput();
+    input.message = "it goes to a new branch";
+
+    // Enable the notification. 'admin' as a reviewer should be notified.
+    input.destination = "branch-1";
+    input.notify = NotifyHandling.ALL;
+    sender.clear();
+    gApi.changes().id(changeId).current().cherryPick(input);
+    assertNotifyCc(admin);
+
+    // Disable the notification. 'admin' as a reviewer should not be notified any more.
+    input.destination = "branch-2";
+    input.notify = NotifyHandling.NONE;
+    sender.clear();
+    gApi.changes().id(changeId).current().cherryPick(input);
+    assertThat(sender.getMessages()).hasSize(0);
+
+    // Disable the notification. The user provided in the 'notifyDetails' should still be notified.
+    TestAccount userToNotify = accountCreator.user2();
+    input.destination = "branch-3";
+    input.notify = NotifyHandling.NONE;
+    input.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+    sender.clear();
+    gApi.changes().id(changeId).current().cherryPick(input);
+    assertNotifyTo(userToNotify);
+  }
+
+  @Test
+  public void cherryPickKeepReviewers() throws Exception {
+    createBranch(new Branch.NameKey(project, "stable"));
+
+    // Change is created by 'admin'.
+    PushOneCommit.Result r = createChange();
+    // Change is approved by 'admin2'. Change is CC'd to 'user'.
+    setApiUser(accountCreator.admin2());
+    ReviewInput in = ReviewInput.approve();
+    in.reviewer(user.email, ReviewerState.CC, true);
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    // Change is cherrypicked by 'user2'.
+    setApiUser(accountCreator.user2());
+    CherryPickInput cin = new CherryPickInput();
+    cin.message = "this need to go to stable";
+    cin.destination = "stable";
+    cin.keepReviewers = true;
+    Map<ReviewerState, Collection<AccountInfo>> result =
+        gApi.changes().id(r.getChangeId()).current().cherryPick(cin).get().reviewers;
+
+    // 'admin' should be a reviewer as the old owner.
+    // 'admin2' should be a reviewer as the old reviewer.
+    // 'user' should be on CC.
+    assertThat(result).containsKey(ReviewerState.REVIEWER);
+    List<Integer> reviewers =
+        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    if (notesMigration.readChanges()) {
+      assertThat(result).containsKey(ReviewerState.CC);
+      List<Integer> ccs =
+          result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+      assertThat(ccs).containsExactly(user.id.get());
+      assertThat(reviewers).containsExactly(admin.id.get(), accountCreator.admin2().id.get());
+    } else {
+      assertThat(reviewers)
+          .containsExactly(user.id.get(), admin.id.get(), accountCreator.admin2().id.get());
+    }
+  }
+
+  @Test
+  public void cherryPickToMergedChangeRevision() throws Exception {
+    createBranch(new Branch.NameKey(project, "foo"));
+
+    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
+    dstChange.assertOkStatus();
+
+    merge(dstChange);
+
+    PushOneCommit.Result result = createChange(testRepo, "foo", SUBJECT, "b.txt", "c", "t");
+    result.assertOkStatus();
+    merge(result);
+
+    PushOneCommit.Result srcChange = createChange();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.base = dstChange.getCommit().name();
+    input.message = srcChange.getCommit().getFullMessage();
+    ChangeInfo changeInfo =
+        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
+  }
+
+  @Test
+  public void cherryPickToOpenChangeRevision() throws Exception {
+    createBranch(new Branch.NameKey(project, "foo"));
+
+    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
+    dstChange.assertOkStatus();
+
+    PushOneCommit.Result srcChange = createChange();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.base = dstChange.getCommit().name();
+    input.message = srcChange.getCommit().getFullMessage();
+    ChangeInfo changeInfo =
+        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
+  }
+
+  @Test
+  public void cherryPickToNonVisibleChangeFails() throws Exception {
+    createBranch(new Branch.NameKey(project, "foo"));
+
+    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
+    dstChange.assertOkStatus();
+
+    gApi.changes().id(dstChange.getChangeId()).setPrivate(true, null);
+
+    PushOneCommit.Result srcChange = createChange();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.base = dstChange.getCommit().name();
+    input.message = srcChange.getCommit().getFullMessage();
+
+    setApiUser(user);
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage(
+        String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
+    gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+  }
+
+  @Test
+  public void cherryPickToAbandonedChangeFails() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+    gApi.changes().id(change2.getChangeId()).abandon();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "master";
+    input.base = change2.getCommit().name();
+    input.message = change1.getCommit().getFullMessage();
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format(
+            "Change %s with commit %s is %s",
+            change2.getChange().getId().get(), input.base, ChangeStatus.ABANDONED));
+    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+  }
+
+  @Test
+  public void cherryPickWithInvalidBaseFails() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "master";
+    input.base = "invalid-sha1";
+    input.message = change1.getCommit().getFullMessage();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(String.format("Base %s doesn't represent a valid SHA-1", input.base));
+    gApi.changes().id(change1.getChangeId()).current().cherryPick(input);
+  }
+
+  @Test
+  public void cherryPickToCommitWithoutChangeId() throws Exception {
+    RevCommit commit1 = createNewCommitWithoutChangeId("refs/heads/foo", "a.txt", "content 1");
+
+    createNewCommitWithoutChangeId("refs/heads/foo", "a.txt", "content 2");
+
+    PushOneCommit.Result srcChange = createChange("subject", "b.txt", "b");
+    srcChange.assertOkStatus();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.base = commit1.name();
+    input.message = srcChange.getCommit().getFullMessage();
+    ChangeInfo changeInfo =
+        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
+    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
+  }
+
+  @Test
   public void canRebase() throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
@@ -616,6 +865,21 @@
   }
 
   @Test
+  public void setUnsetReviewedFlagByFileApi() throws Exception {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+
+    gApi.changes().id(r.getChangeId()).current().file(PushOneCommit.FILE_NAME).setReviewed(true);
+
+    assertThat(Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).current().reviewed()))
+        .isEqualTo(PushOneCommit.FILE_NAME);
+
+    gApi.changes().id(r.getChangeId()).current().file(PushOneCommit.FILE_NAME).setReviewed(false);
+
+    assertThat(gApi.changes().id(r.getChangeId()).current().reviewed()).isEmpty();
+  }
+
+  @Test
   public void mergeable() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
@@ -655,83 +919,168 @@
     PushOneCommit.Result r = createChange();
     Map<String, FileInfo> files =
         gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files();
-    assertThat(files).hasSize(2);
-    assertThat(Iterables.all(files.keySet(), f -> f.matches(FILE_NAME + '|' + COMMIT_MSG)))
-        .isTrue();
+    assertThat(files.keySet()).containsExactly(FILE_NAME, COMMIT_MSG);
   }
 
   @Test
   public void filesOnMergeCommitChange() throws Exception {
     PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
 
-    // list files against auto-merge
+    // List files against auto-merge
     assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files().keySet())
         .containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
 
-    // list files against parent 1
+    // List files against parent 1
     assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(1).keySet())
         .containsExactly(COMMIT_MSG, MERGE_LIST, "bar");
 
-    // list files against parent 2
+    // List files against parent 2
     assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(2).keySet())
         .containsExactly(COMMIT_MSG, MERGE_LIST, "foo");
   }
 
   @Test
-  public void diff() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertDiffForNewFile(r, FILE_NAME, FILE_CONTENT);
-    assertDiffForNewFile(r, COMMIT_MSG, r.getCommit().getFullMessage());
-  }
-
-  @Test
-  public void diffDeletedFile() throws Exception {
-    pushFactory.create(db, admin.getIdent(), testRepo).to("refs/heads/master");
-    PushOneCommit.Result r =
-        pushFactory.create(db, admin.getIdent(), testRepo).rm("refs/for/master");
-    DiffInfo diff =
-        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file(FILE_NAME).diff();
-    assertThat(diff.metaA.lines).isEqualTo(1);
-    assertThat(diff.metaB).isNull();
-  }
-
-  @Test
-  public void diffOnMergeCommitChange() throws Exception {
+  public void filesOnMergeCommitChangeWithInvalidParent() throws Exception {
     PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
 
-    DiffInfo diff;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .revision(r.getCommit().name())
+                    .files(3)
+                    .keySet());
+    assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 3");
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .revision(r.getCommit().name())
+                    .files(-1)
+                    .keySet());
+    assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -1");
+  }
 
-    // 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);
+  @Test
+  public void listFilesWithInvalidParent() throws Exception {
+    PushOneCommit.Result result1 = createChange();
+    String changeId = result1.getChangeId();
+    PushOneCommit.Result result2 = amendChange(changeId, SUBJECT, "b.txt", "b");
+    String revId2 = result2.getCommit().name();
 
-    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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).revision(revId2).files(2).keySet());
+    assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 2");
 
-    // 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);
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).revision(revId2).files(-1).keySet());
+    assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -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 listFilesOnDifferentBases() throws Exception {
+    RevCommit initialCommit = getHead(repo());
+
+    PushOneCommit.Result result1 = createChange();
+    String changeId = result1.getChangeId();
+    PushOneCommit.Result result2 = amendChange(changeId, SUBJECT, "b.txt", "b");
+    PushOneCommit.Result result3 = amendChange(changeId, SUBJECT, "c.txt", "c");
+
+    String revId1 = result1.getCommit().name();
+    String revId2 = result2.getCommit().name();
+    String revId3 = result3.getCommit().name();
+
+    assertThat(gApi.changes().id(changeId).revision(revId1).files(null).keySet())
+        .containsExactly(COMMIT_MSG, "a.txt");
+    assertThat(gApi.changes().id(changeId).revision(revId2).files(null).keySet())
+        .containsExactly(COMMIT_MSG, "a.txt", "b.txt");
+    assertThat(gApi.changes().id(changeId).revision(revId3).files(null).keySet())
+        .containsExactly(COMMIT_MSG, "a.txt", "b.txt", "c.txt");
+
+    assertThat(gApi.changes().id(changeId).revision(revId2).files(revId1).keySet())
+        .containsExactly(COMMIT_MSG, "b.txt");
+    assertThat(gApi.changes().id(changeId).revision(revId3).files(revId1).keySet())
+        .containsExactly(COMMIT_MSG, "b.txt", "c.txt");
+    assertThat(gApi.changes().id(changeId).revision(revId3).files(revId2).keySet())
+        .containsExactly(COMMIT_MSG, "c.txt");
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(changeId).revision(revId3).files(initialCommit.getName()));
+    assertThat(thrown).hasMessageThat().contains(initialCommit.getName());
+
+    String invalidRev = "deadbeef";
+    thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(changeId).revision(revId3).files(invalidRev));
+    assertThat(thrown).hasMessageThat().contains(invalidRev);
+  }
+
+  @Test
+  public void queryRevisionFiles() throws Exception {
+    Map<String, String> files = ImmutableMap.of("file1.txt", "content 1", "file2.txt", "content 2");
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo, SUBJECT, files).to("refs/for/master");
+    result.assertOkStatus();
+    String changeId = result.getChangeId();
+
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file1.txt"))
+        .containsExactly("file1.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file2.txt"))
+        .containsExactly("file2.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file1"))
+        .containsExactly("file1.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file2"))
+        .containsExactly("file2.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles("file"))
+        .containsExactly("file1.txt", "file2.txt");
+    assertThat(gApi.changes().id(changeId).current().queryFiles(""))
+        .containsExactly("file1.txt", "file2.txt");
   }
 
   @Test
   public void description() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo("");
+    assertDescription(r, "");
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo("test");
+    assertDescription(r, "test");
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("");
+    assertDescription(r, "");
+  }
+
+  @Test
+  public void setDescriptionNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertDescription(r, "");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("edit description not permitted");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+  }
+
+  @Test
+  public void setDescriptionAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertDescription(r, "");
+    grant(project, "refs/heads/master", Permission.OWNER, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+    assertDescription(r, "test");
+  }
+
+  private void assertDescription(PushOneCommit.Result r, String expected) throws Exception {
     assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo("");
+        .isEqualTo(expected);
   }
 
   @Test
@@ -759,6 +1108,47 @@
     assertThat(response.hasContent()).isFalse();
   }
 
+  @Test
+  public void commit() throws Exception {
+    WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
+    patchSetLinks.add(
+        new PatchSetWebLink() {
+          @Override
+          public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+            return expectedWebLinkInfo;
+          }
+        });
+
+    PushOneCommit.Result r = createChange();
+    RevCommit c = r.getCommit();
+
+    CommitInfo commitInfo = gApi.changes().id(r.getChangeId()).current().commit(false);
+    assertThat(commitInfo.commit).isEqualTo(c.name());
+    assertPersonIdent(commitInfo.author, c.getAuthorIdent());
+    assertPersonIdent(commitInfo.committer, c.getCommitterIdent());
+    assertThat(commitInfo.message).isEqualTo(c.getFullMessage());
+    assertThat(commitInfo.subject).isEqualTo(c.getShortMessage());
+    assertThat(commitInfo.parents).hasSize(1);
+    assertThat(Iterables.getOnlyElement(commitInfo.parents).commit)
+        .isEqualTo(c.getParent(0).name());
+    assertThat(commitInfo.webLinks).isNull();
+
+    commitInfo = gApi.changes().id(r.getChangeId()).current().commit(true);
+    assertThat(commitInfo.webLinks).hasSize(1);
+    WebLinkInfo webLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
+    assertThat(webLinkInfo.name).isEqualTo(expectedWebLinkInfo.name);
+    assertThat(webLinkInfo.imageUrl).isEqualTo(expectedWebLinkInfo.imageUrl);
+    assertThat(webLinkInfo.url).isEqualTo(expectedWebLinkInfo.url);
+    assertThat(webLinkInfo.target).isEqualTo(expectedWebLinkInfo.target);
+  }
+
+  private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
+    assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
+    assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
+    assertThat(gitPerson.date).isEqualTo(new Timestamp(expectedIdent.getWhen().getTime()));
+    assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
+  }
+
   private void assertMergeable(String id, boolean expected) throws Exception {
     MergeableInfo m = gApi.changes().id(id).current().mergeable();
     assertThat(m.mergeable).isEqualTo(expected);
@@ -858,6 +1248,26 @@
   }
 
   @Test
+  public void commentOnNonExistingFile() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r = updateChange(r, "new content");
+    CommentInput in = new CommentInput();
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = "non-existing.txt";
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<CommentInput>> comments = new HashMap<>();
+    comments.put("non-existing.txt", Collections.singletonList(in));
+    reviewInput.comments = comments;
+    reviewInput.message = "comment test";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        String.format("not found in revision %d,1", r.getChange().change().getId().id));
+    gApi.changes().id(r.getChangeId()).revision(1).review(reviewInput);
+  }
+
+  @Test
   public void patch() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeApi changeApi = gApi.changes().id(r.getChangeId());
@@ -969,10 +1379,61 @@
     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");
-    assertThat(getReviewers(c.reviewers.get(REVIEWER)))
+    assertThat(getReviewers(c.reviewers.get(ReviewerState.REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.getId(), user.getId()));
   }
 
+  @Test
+  public void listVotesByRevision() throws Exception {
+    // Create patch set 1 and vote on it
+    String changeId = createChange().getChangeId();
+    ListMultimap<String, ApprovalInfo> votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes).isEmpty();
+    recommend(changeId);
+    votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    List<ApprovalInfo> approvals = votes.get("Code-Review");
+    assertThat(approvals).hasSize(1);
+    ApprovalInfo approval = approvals.get(0);
+    assertThat(approval._accountId).isEqualTo(admin.id.get());
+    assertThat(approval.email).isEqualTo(admin.email);
+    assertThat(approval.username).isEqualTo(admin.username);
+
+    // Also vote on it with another user
+    setApiUser(user);
+    gApi.changes().id(changeId).current().review(ReviewInput.dislike());
+
+    // Patch set 1 has 2 votes on Code-Review
+    setApiUser(admin);
+    votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    approvals = votes.get("Code-Review");
+    assertThat(approvals).hasSize(2);
+    assertThat(approvals.stream().map(a -> a._accountId))
+        .containsExactlyElementsIn(ImmutableList.of(admin.id.get(), user.id.get()));
+
+    // Create a new patch set which does not have any votes
+    amendChange(changeId);
+    votes = gApi.changes().id(changeId).current().votes();
+    assertThat(votes).isEmpty();
+
+    // Votes are still returned for ps 1
+    votes = gApi.changes().id(changeId).revision(1).votes();
+    assertThat(votes.keySet()).containsExactly("Code-Review");
+    approvals = votes.get("Code-Review");
+    assertThat(approvals).hasSize(2);
+  }
+
+  private static void assertCherryPickResult(
+      ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) throws Exception {
+    assertThat(changeInfo.changeId).isEqualTo(srcChangeId);
+    assertThat(changeInfo.revisions.keySet()).containsExactly(changeInfo.currentRevision);
+    RevisionInfo revisionInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revisionInfo.commit.message).isEqualTo(input.message);
+    assertThat(revisionInfo.commit.parents).hasSize(1);
+    assertThat(revisionInfo.commit.parents.get(0).commit).isEqualTo(input.base);
+  }
+
   private PushOneCommit.Result updateChange(PushOneCommit.Result r, String content)
       throws Exception {
     PushOneCommit push =
@@ -981,11 +1442,6 @@
     return push.to("refs/for/master");
   }
 
-  private PushOneCommit.Result createDraft() throws Exception {
-    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();
   }
@@ -997,59 +1453,6 @@
     return eTag;
   }
 
-  private void assertContent(PushOneCommit.Result pushResult, String path, String expectedContent)
-      throws Exception {
-    BinaryResult bin =
-        gApi.changes()
-            .id(pushResult.getChangeId())
-            .revision(pushResult.getCommit().name())
-            .file(path)
-            .content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String res = new String(os.toByteArray(), UTF_8);
-    assertThat(res).isEqualTo(expectedContent);
-  }
-
-  private void assertDiffForNewFile(
-      PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
-    DiffInfo diff =
-        gApi.changes()
-            .id(pushResult.getChangeId())
-            .revision(pushResult.getCommit().name())
-            .file(path)
-            .diff();
-
-    List<String> headers = new ArrayList<>();
-    if (path.equals(COMMIT_MSG)) {
-      RevCommit c = pushResult.getCommit();
-
-      RevCommit parentCommit = c.getParents()[0];
-      String parentCommitId =
-          testRepo.getRevWalk().getObjectReader().abbreviate(parentCommit.getId(), 8).name();
-      headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
-
-      SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
-      PersonIdent author = c.getAuthorIdent();
-      dtfmt.setTimeZone(author.getTimeZone());
-      headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
-      headers.add("AuthorDate: " + dtfmt.format(Long.valueOf(author.getWhen().getTime())));
-
-      PersonIdent committer = c.getCommitterIdent();
-      dtfmt.setTimeZone(committer.getTimeZone());
-      headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
-      headers.add("CommitDate: " + dtfmt.format(Long.valueOf(committer.getWhen().getTime())));
-      headers.add("");
-    }
-
-    if (!headers.isEmpty()) {
-      String header = Joiner.on("\n").join(headers);
-      expectedContentSideB = header + "\n" + expectedContentSideB;
-    }
-
-    assertDiffForNewFile(diff, pushResult.getCommit(), path, expectedContentSideB);
-  }
-
   private PushOneCommit.Result createCherryPickableMerge(
       String parent1FileName, String parent2FileName) throws Exception {
     RevCommit initialCommit = getHead(repo());
@@ -1085,7 +1488,7 @@
   }
 
   private ApprovalInfo getApproval(String changeId, String label) throws Exception {
-    ChangeInfo info = gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS));
+    ChangeInfo info = gApi.changes().id(changeId).get(DETAILED_LABELS);
     LabelInfo li = info.labels.get(label);
     assertThat(li).isNotNull();
     int accountId = atrScope.get().getUser().getAccountId().get();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 11df473..27f8a2f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -16,34 +16,51 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.RobotCommentInfoSubject.assertThatList;
+import static java.util.stream.Collectors.toList;
 
+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.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.BinaryResultSubject;
 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.RestApiException;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import org.hamcrest.core.StringContains;
+import java.util.Objects;
+import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
 
 public class RobotCommentsIT extends AbstractDaemonTest {
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+
   private String changeId;
   private FixReplacementInfo fixReplacementInfo;
   private FixSuggestionInfo fixSuggestionInfo;
@@ -51,7 +68,14 @@
 
   @Before
   public void setUp() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
     changeId = changeResult.getChangeId();
 
     fixReplacementInfo = createFixReplacementInfo();
@@ -61,7 +85,7 @@
 
   @Test
   public void retrievingRobotCommentsBeforeAddingAnyDoesNotRaiseAnException() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     Map<String, List<RobotCommentInfo>> robotComments =
         gApi.changes().id(changeId).current().robotComments();
@@ -72,7 +96,7 @@
 
   @Test
   public void addedRobotCommentsCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput in = createRobotCommentInput();
     addRobotComment(changeId, in);
@@ -86,7 +110,7 @@
 
   @Test
   public void addedRobotCommentsCanBeRetrievedByChange() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput in = createRobotCommentInput();
     addRobotComment(changeId, in);
@@ -109,7 +133,7 @@
 
   @Test
   public void robotCommentsCanBeRetrievedAsList() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput robotCommentInput = createRobotCommentInput();
     addRobotComment(changeId, robotCommentInput);
@@ -124,7 +148,7 @@
 
   @Test
   public void specificRobotCommentCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput robotCommentInput = createRobotCommentInput();
     addRobotComment(changeId, robotCommentInput);
@@ -139,7 +163,7 @@
 
   @Test
   public void robotCommentWithoutOptionalFieldsCanBeAdded() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
     addRobotComment(changeId, in);
@@ -151,8 +175,77 @@
   }
 
   @Test
+  public void hugeRobotCommentIsRejected() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    int sizeOfRest = 451;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest + 1);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("limit");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void reasonablyLargeRobotCommentIsAccepted() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    int sizeOfRest = 451;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "10k")
+  public void maximumAllowedSizeOfRobotCommentCanBeAdjusted() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int sizeLimit = 10 * 1024;
+    fixReplacementInfo.replacement = getStringFor(sizeLimit);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("limit");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "0")
+  public void zeroForMaximumAllowedSizeOfRobotCommentRemovesRestriction() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "-1")
+  public void negativeValueForMaximumAllowedSizeOfRobotCommentRemovesRestriction()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
   public void addedFixSuggestionCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -162,7 +255,7 @@
 
   @Test
   public void fixIdIsGeneratedForFixSuggestion() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -177,7 +270,7 @@
 
   @Test
   public void descriptionOfFixSuggestionIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -191,7 +284,7 @@
 
   @Test
   public void descriptionOfFixSuggestionIsMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixSuggestionInfo.description = null;
 
@@ -205,7 +298,7 @@
 
   @Test
   public void addedFixReplacementCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -219,7 +312,7 @@
 
   @Test
   public void fixReplacementsAreMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixSuggestionInfo.replacements = Collections.emptyList();
 
@@ -234,7 +327,7 @@
 
   @Test
   public void pathOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
 
@@ -250,7 +343,7 @@
 
   @Test
   public void pathOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixReplacementInfo.path = null;
 
@@ -263,23 +356,8 @@
   }
 
   @Test
-  public void pathOfFixReplacementMustReferToFileOfComment() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
-
-    fixReplacementInfo.path = "anotherFile.txt";
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "Replacements may only be specified "
-                + "for the file %s on which the robot comment was added",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
   public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
 
@@ -295,7 +373,7 @@
 
   @Test
   public void rangeOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixReplacementInfo.range = null;
 
@@ -309,17 +387,121 @@
 
   @Test
   public void rangeOfFixReplacementNeedsToBeValid() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixReplacementInfo.range = createRange(13, 9, 5, 10);
     exception.expect(BadRequestException.class);
-    exception.expectMessage(new StringContains("Range (13:9 - 5:10)"));
+    exception.expectMessage("Range (13:9 - 5:10)");
     addRobotComment(changeId, withFixRobotCommentInput);
   }
 
   @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("overlap");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForDifferentFileMayOverlap()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(1);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfDifferentFixSuggestionsForSameFileMayOverlap()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixRobotCommentInput.fixSuggestions =
+        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(2);
+  }
+
+  @Test
+  public void fixReplacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixReplacementInfo fixReplacementInfo3 = new FixReplacementInfo();
+    fixReplacementInfo3.path = FILE_NAME;
+    fixReplacementInfo3.range = createRange(4, 0, 5, 0);
+    fixReplacementInfo3.replacement = "Third modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo2, fixReplacementInfo1, fixReplacementInfo3);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().replacements().hasSize(3);
+  }
+
+  @Test
   public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
 
@@ -335,7 +517,7 @@
 
   @Test
   public void replacementStringOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixReplacementInfo.replacement = null;
 
@@ -349,13 +531,490 @@
   }
 
   @Test
+  public void fixWithinALineCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixSpanningMultipleLinesCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content\n5";
+    fixReplacementInfo.range = createRange(3, 2, 5, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nThModified content\n5th line\nSixth line\nSeventh line\n"
+                + "Eighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nSome other modified content\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoFixesOnSameFileCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
+    addRobotComment(changeId, robotCommentInput1);
+    addRobotComment(changeId, robotCommentInput2);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoConflictingFixesOnSameFileCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
+    addRobotComment(changeId, robotCommentInput1);
+    addRobotComment(changeId, robotCommentInput2);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("merge");
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+  }
+
+  @Test
+  public void twoFixesOfSameRobotCommentCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixRobotCommentInput.fixSuggestions =
+        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME2;
+    fixReplacementInfo.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("1st line\nModified content\n3rd line\n");
+  }
+
+  @Test
+  public void fixInvolvingTwoFilesCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "Different file modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file2)
+        .value()
+        .asString()
+        .isEqualTo("Different file modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void fixReferringToNonExistentFileCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = "a_non_existent_file.txt";
+    fixReplacementInfo.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(changeId).current().applyFix(fixId);
+  }
+
+  @Test
+  public void fixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("current");
+    gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+  }
+
+  @Test
+  public void fixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+    assertThat(editInfo).baseRevision().isEqualTo(previousRevision);
+  }
+
+  @Test
+  public void fixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    // Add another patch set.
+    amendChange(changeId);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("based");
+    gApi.changes().id(changeId).current().applyFix(fixId);
+  }
+
+  @Test
+  public void fixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    String changeEditCommitMessage = "This is the commit message of the change edit.\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(changeEditCommitMessage);
+  }
+
+  @Test
+  public void applyingFixTwiceIsIdempotent() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+    String expectedEditCommit =
+        gApi.changes().id(changeId).edit().get().map(edit -> edit.commit.commit).orElse("");
+
+    // Apply the fix again.
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> editInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(editInfo).value().commit().commit().isEqualTo(expectedEditCommit);
+  }
+
+  @Test
+  public void nonExistentFixCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+    String nonExistentFixId = fixId + "_non-existent";
+
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(changeId).current().applyFix(nonExistentFixId);
+  }
+
+  @Test
+  public void applyingFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void applyingFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void createdChangeEditIsBasedOnCurrentPatchSet() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    assertThat(editInfo).baseRevision().isEqualTo(currentRevision);
+  }
+
+  @Test
   public void robotCommentsNotSupportedWithoutNoteDb() throws Exception {
-    assume().that(notesMigration.enabled()).isFalse();
+    assume().that(notesMigration.readChanges()).isFalse();
 
     RobotCommentInput in = createRobotCommentInput();
     ReviewInput reviewInput = new ReviewInput();
     Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
-    robotComments.put(FILE_NAME, Collections.singletonList(in));
+    robotComments.put(in.path, ImmutableList.of(in));
     reviewInput.robotComments = robotComments;
     reviewInput.message = "comment test";
 
@@ -366,7 +1025,7 @@
 
   @Test
   public void queryChangesWithUnresolvedCommentCount() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 =
@@ -389,7 +1048,7 @@
     }
   }
 
-  private RobotCommentInput createRobotCommentInputWithMandatoryFields() {
+  private static RobotCommentInput createRobotCommentInputWithMandatoryFields() {
     RobotCommentInput in = new RobotCommentInput();
     in.robotId = "happyRobot";
     in.robotRunId = "1";
@@ -399,7 +1058,8 @@
     return in;
   }
 
-  private RobotCommentInput createRobotCommentInput(FixSuggestionInfo... fixSuggestionInfos) {
+  private static RobotCommentInput createRobotCommentInput(
+      FixSuggestionInfo... fixSuggestionInfos) {
     RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
     in.url = "http://www.happy-robot.com";
     in.properties = new HashMap<>();
@@ -409,7 +1069,8 @@
     return in;
   }
 
-  private FixSuggestionInfo createFixSuggestionInfo(FixReplacementInfo... fixReplacementInfos) {
+  private static FixSuggestionInfo createFixSuggestionInfo(
+      FixReplacementInfo... fixReplacementInfos) {
     FixSuggestionInfo newFixSuggestionInfo = new FixSuggestionInfo();
     newFixSuggestionInfo.fixId = "An ID which must be overwritten.";
     newFixSuggestionInfo.description = "A description for a suggested fix.";
@@ -417,15 +1078,15 @@
     return newFixSuggestionInfo;
   }
 
-  private FixReplacementInfo createFixReplacementInfo() {
+  private static FixReplacementInfo createFixReplacementInfo() {
     FixReplacementInfo newFixReplacementInfo = new FixReplacementInfo();
     newFixReplacementInfo.path = FILE_NAME;
     newFixReplacementInfo.replacement = "some replacement code";
-    newFixReplacementInfo.range = createRange(3, 12, 15, 4);
+    newFixReplacementInfo.range = createRange(3, 9, 8, 4);
     return newFixReplacementInfo;
   }
 
-  private Comment.Range createRange(
+  private static Comment.Range createRange(
       int startLine, int startCharacter, int endLine, int endCharacter) {
     Comment.Range range = new Comment.Range();
     range.startLine = startLine;
@@ -439,8 +1100,7 @@
       throws Exception {
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.robotComments =
-        Collections.singletonMap(
-            robotCommentInput.path, Collections.singletonList(robotCommentInput));
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
     reviewInput.message = "robot comment test";
     gApi.changes().id(targetChangeId).current().review(reviewInput);
   }
@@ -470,4 +1130,21 @@
       assertThat(c.path).isNull();
     }
   }
+
+  private static String getStringFor(int numberOfBytes) {
+    char[] chars = new char[numberOfBytes];
+    // 'a' will require one byte even when mapped to a JSON string
+    Arrays.fill(chars, 'a');
+    return new String(chars);
+  }
+
+  private static List<String> getFixIds(List<RobotCommentInfo> robotComments) {
+    assertThatList(robotComments).isNotNull();
+    return robotComments.stream()
+        .map(robotCommentInfo -> robotCommentInfo.fixSuggestions)
+        .filter(Objects::nonNull)
+        .flatMap(List::stream)
+        .map(fixSuggestionInfo -> fixSuggestionInfo.fixId)
+        .collect(toList());
+  }
 }
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 9cb0b31..dd26e6e 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
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.restapi.BinaryResultSubject.assertThat;
+import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 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;
@@ -25,6 +26,7 @@
 
 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.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
@@ -36,6 +38,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -46,10 +49,10 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.reviewdb.client.Patch;
 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.ChangeMessagesUtil;
 import com.google.gerrit.server.change.ChangeEdits.EditMessage;
 import com.google.gerrit.server.change.ChangeEdits.Post;
 import com.google.gerrit.server.change.ChangeEdits.Put;
@@ -161,6 +164,23 @@
             "Uploaded patch set 1.",
             "Uploaded patch set 2.",
             "Patch Set 3: Published edit on patch set 2."));
+
+    // The tag for the publish edit change message should vary according
+    // to whether the change was WIP at the time of publishing.
+    ChangeInfo info = get(changeId);
+    assertThat(info.messages).isNotEmpty();
+    assertThat(Iterables.getLast(info.messages).tag)
+        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+
+    // Move the change to WIP, repeat, and verify.
+    gApi.changes().id(changeId).setWorkInProgress();
+    createEmptyEditFor(changeId);
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW2));
+    gApi.changes().id(changeId).edit().publish();
+    info = get(changeId);
+    assertThat(info.messages).isNotEmpty();
+    assertThat(Iterables.getLast(info.messages).tag)
+        .isEqualTo(ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
   }
 
   @Test
@@ -395,7 +415,9 @@
   public void retrieveEdit() throws Exception {
     adminRestSession.get(urlEdit(changeId)).assertNoContent();
     createArbitraryEditFor(changeId);
-    EditInfo editInfo = getEditInfo(changeId, false);
+    Optional<EditInfo> maybeEditInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(maybeEditInfo).isPresent();
+    EditInfo editInfo = maybeEditInfo.get();
     ChangeInfo changeInfo = get(changeId);
     assertThat(editInfo.commit.commit).isNotEqualTo(changeInfo.currentRevision);
     assertThat(editInfo).commit().parents().hasSize(1);
@@ -409,11 +431,7 @@
   @Test
   public void retrieveFilesInEdit() throws Exception {
     createEmptyEditFor(changeId);
-    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
-
-    EditInfo info = getEditInfo(changeId, true);
-    assertThat(info.files).isNotNull();
-    assertThat(info.files.keySet()).containsExactly(Patch.COMMIT_MSG, FILE_NAME, FILE_NAME2);
+    assertFiles(changeId, ImmutableList.of(COMMIT_MSG, FILE_NAME, FILE_NAME2));
   }
 
   @Test
@@ -554,8 +572,10 @@
   @Test
   public void addNewFile() throws Exception {
     createEmptyEditFor(changeId);
+    assertFiles(changeId, ImmutableList.of(COMMIT_MSG, FILE_NAME, FILE_NAME2));
     gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
     ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
+    assertFiles(changeId, ImmutableList.of(COMMIT_MSG, FILE_NAME, FILE_NAME2, FILE_NAME3));
   }
 
   @Test
@@ -671,7 +691,7 @@
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
 
     // Create change as user
     PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
@@ -741,6 +761,19 @@
     assertThat(fileContent).value().bytes().isEqualTo(expectedFileBytes);
   }
 
+  private void assertFiles(String changeId, List<String> expected) throws Exception {
+    Optional<EditInfo> info =
+        gApi.changes()
+            .id(changeId)
+            .edit()
+            .detail()
+            .withOption(ChangeEditDetailOption.LIST_FILES)
+            .get();
+    assertThat(info).isPresent();
+    assertThat(info.get().files).isNotNull();
+    assertThat(info.get().files.keySet()).containsExactlyElementsIn(expected);
+  }
+
   private String urlEdit(String changeId) {
     return "/changes/" + changeId + "/edit";
   }
@@ -757,10 +790,6 @@
     return urlEdit(changeId) + "/" + fileName + (base ? "?base" : "");
   }
 
-  private String urlGetFiles(String changeId) {
-    return urlEdit(changeId) + "?list";
-  }
-
   private String urlRevisionFiles(String changeId, String revisionId) {
     return "/changes/" + changeId + "/revisions/" + revisionId + "/files";
   }
@@ -795,23 +824,20 @@
         + "/diff?context=ALL&intraline";
   }
 
-  private EditInfo getEditInfo(String changeId, boolean files) throws Exception {
-    RestResponse r = adminRestSession.get(files ? urlGetFiles(changeId) : urlEdit(changeId));
-    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, clazz);
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      jsonReader.setLenient(true);
+      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());
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      jsonReader.setLenient(true);
+      return newGson().fromJson(jsonReader, typeToken.getType());
+    }
   }
 
   private String readContentFromJson(RestResponse r) throws Exception {
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 d05b601..f07673b 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
@@ -15,66 +15,102 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.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.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
 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 static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
 
 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.Streams;
 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.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 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.DraftInput;
 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.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.Side;
 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.common.EditInfo;
+import com.google.gerrit.extensions.common.EditInfoSubject;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+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.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.client.RefNames;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.receive.ReceiveConstants;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+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 com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
 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.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
@@ -89,6 +125,8 @@
 
   private LabelType patchSetLock;
 
+  @Inject private DynamicSet<CommitValidationListener> commitValidators;
+
   @BeforeClass
   public static void setTimeForTesting() {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -108,7 +146,15 @@
     Util.allow(
         cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, anonymousUsers, "refs/heads/*");
     saveProjectConfig(cfg);
-    grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
+    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    setApiUser(admin);
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.publishCommentsOnPush = false;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
   }
 
   protected void selectProtocol(Protocol p) throws Exception {
@@ -151,11 +197,90 @@
     try (Repository repo = repoManager.openRepository(project)) {
       assertThat(repo.resolve("master")).isNull();
     }
+
+    gApi.changes().id(change.id).current().review(ReviewInput.approve());
+    gApi.changes().id(change.id).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("master")).isEqualTo(c);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "receive.enableSignedPush", value = "true")
+  @TestProjectInput(
+      enableSignedPush = InheritableBoolean.TRUE,
+      requireSignedPush = InheritableBoolean.TRUE)
+  public void nonSignedPushRejectedWhenSignPushRequired() throws Exception {
+    pushTo("refs/for/master").assertErrorStatus("push cert error");
+  }
+
+  @Test
+  public void pushInitialCommitForRefsMetaConfigBranch() throws Exception {
+    // delete refs/meta/config
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
+      u.setForceUpdate(true);
+      u.setExpectedOldObjectId(repo.resolve(RefNames.REFS_CONFIG));
+      assertThat(u.delete(rw)).isEqualTo(Result.FORCED);
+    }
+
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("Initial commit")
+            .author(admin.getIdent())
+            .committer(admin.getIdent())
+            .insertChangeId()
+            .create();
+    String id = GitUtil.getChangeId(testRepo, c).get();
+    testRepo.reset(c);
+
+    String r = "refs/for/" + RefNames.REFS_CONFIG;
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushOk(pr, r);
+
+    ChangeInfo change = gApi.changes().id(id).info();
+    assertThat(change.branch).isEqualTo(RefNames.REFS_CONFIG);
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve(RefNames.REFS_CONFIG)).isNull();
+    }
+
+    gApi.changes().id(change.id).current().review(ReviewInput.approve());
+    gApi.changes().id(change.id).current().submit();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve(RefNames.REFS_CONFIG)).isEqualTo(c);
+    }
+  }
+
+  @Test
+  public void pushInitialCommitForNormalNonExistingBranchFails() throws Exception {
+    RevCommit c =
+        testRepo
+            .commit()
+            .message("Initial commit")
+            .author(admin.getIdent())
+            .committer(admin.getIdent())
+            .insertChangeId()
+            .create();
+    testRepo.reset(c);
+
+    String r = "refs/for/foo";
+    PushResult pr = pushHead(testRepo, r, false);
+    assertPushRejected(pr, r, "branch foo not found");
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.resolve("foo")).isNull();
+    }
   }
 
   @Test
   public void output() throws Exception {
-    String url = canonicalWebUrl.get();
+    String url = canonicalWebUrl.get() + "#/c/" + project.get() + "/+/";
     ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
     PushOneCommit.Result r1 = pushTo("refs/for/master");
     Change.Id id1 = r1.getChange().getId();
@@ -197,6 +322,22 @@
   }
 
   @Test
+  public void autoclose() throws Exception {
+    // Create a change
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+
+    // Force push it, closing it
+    String master = "refs/heads/master";
+    assertPushOk(pushHead(testRepo, master, false), master);
+
+    // Attempt to push amended commit to same change
+    String url = canonicalWebUrl.get() + "#/c/" + project.get() + "/+/" + r.getChange().getId();
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertErrorStatus("change " + url + " closed");
+  }
+
+  @Test
   public void pushForMasterWithTopic() throws Exception {
     // specify topic in ref
     String topic = "my/topic";
@@ -226,9 +367,23 @@
   }
 
   @Test
+  public void pushForMasterWithTopicInRefExceedLimitFails() throws Exception {
+    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
+    PushOneCommit.Result r = pushTo("refs/for/master/" + topic);
+    r.assertErrorStatus("topic length exceeds the limit (2048)");
+  }
+
+  @Test
+  public void pushForMasterWithTopicAsOptionExceedLimitFails() throws Exception {
+    String topic = Stream.generate(() -> "t").limit(2049).collect(joining());
+    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
+    r.assertErrorStatus("topic length exceeds the limit (2048)");
+  }
+
+  @Test
   public void pushForMasterWithNotify() throws Exception {
     // create a user that watches the project
-    TestAccount user3 = accounts.create("user3", "user3@example.com", "User3");
+    TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3");
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
     ProjectWatchInfo pwi = new ProjectWatchInfo();
     pwi.project = project.get();
@@ -238,7 +393,7 @@
     setApiUser(user3);
     gApi.accounts().self().setWatchedProjects(projectsToWatch);
 
-    TestAccount user2 = accounts.user2();
+    TestAccount user2 = accountCreator.user2();
     String pushSpec = "refs/for/master%reviewer=" + user.email + ",cc=" + user2.email;
 
     sender.clear();
@@ -304,10 +459,9 @@
     String topic = "my/topic";
     PushOneCommit.Result r = pushTo("refs/for/master/" + topic + "%cc=" + user.email);
     r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic);
+    r.assertChange(Change.Status.NEW, topic, ImmutableList.of(), ImmutableList.of(user));
 
     // cc several users
-    TestAccount user2 = accounts.create("another-user", "another.user@example.com", "Another User");
     r =
         pushTo(
             "refs/for/master/"
@@ -317,9 +471,14 @@
                 + ",cc="
                 + user.email
                 + ",cc="
-                + user2.email);
+                + accountCreator.user2().email);
     r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic);
+    // Check that admin isn't CC'd as they own the change
+    r.assertChange(
+        Change.Status.NEW,
+        topic,
+        ImmutableList.of(),
+        ImmutableList.of(user, accountCreator.user2()));
 
     // cc non-existing user
     String nonExistingEmail = "non.existing@example.com";
@@ -345,7 +504,8 @@
     r.assertChange(Change.Status.NEW, topic, user);
 
     // add several reviewers
-    TestAccount user2 = accounts.create("another-user", "another.user@example.com", "Another User");
+    TestAccount user2 =
+        accountCreator.create("another-user", "another.user@example.com", "Another User");
     r =
         pushTo(
             "refs/for/master/"
@@ -376,29 +536,147 @@
   }
 
   @Test
-  public void pushForMasterAsDraft() throws Exception {
-    // create draft by pushing to 'refs/drafts/'
-    PushOneCommit.Result r = pushTo("refs/drafts/master");
+  public void pushPrivateChange() throws Exception {
+    // Push a private change.
+    PushOneCommit.Result r = pushTo("refs/for/master%private");
     r.assertOkStatus();
-    r.assertChange(Change.Status.DRAFT, null);
+    r.assertMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isTrue();
 
-    // create draft by using 'draft' option
-    r = pushTo("refs/for/master%draft");
+    // Pushing a new patch set without --private doesn't remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master");
     r.assertOkStatus();
-    r.assertChange(Change.Status.DRAFT, null);
+    r.assertMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master%remove-private");
+    r.assertOkStatus();
+    r.assertNotMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Normal push: privacy flag is not added back.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    r.assertNotMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Make the change private again.
+    r = pushTo("refs/for/master%private");
+    r.assertOkStatus();
+    r.assertMessage(" [PRIVATE]");
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Can't use --private and --remove-private together.
+    r = pushTo("refs/for/master%private,remove-private");
+    r.assertErrorStatus();
   }
 
   @Test
-  public void publishDraftChangeByPushingNonDraftPatchSet() throws Exception {
-    // create draft change
-    PushOneCommit.Result r = pushTo("refs/drafts/master");
+  public void pushWorkInProgressChange() throws Exception {
+    // Push a work-in-progress change.
+    PushOneCommit.Result r = pushTo("refs/for/master%wip");
     r.assertOkStatus();
-    r.assertChange(Change.Status.DRAFT, null);
+    r.assertMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
 
-    // publish draft change by pushing non-draft patch set
-    r = amendChange(r.getChangeId(), "refs/for/master");
+    // Pushing a new patch set without --wip doesn't remove the wip flag from the change.
+    String changeId = r.getChangeId();
+    r = amendChange(changeId, "refs/for/master");
     r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, null);
+    r.assertMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
+
+    // Remove the wip flag from the change.
+    r = amendChange(changeId, "refs/for/master%ready");
+    r.assertOkStatus();
+    r.assertNotMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+
+    // Normal push: wip flag is not added back.
+    r = amendChange(changeId, "refs/for/master");
+    r.assertOkStatus();
+    r.assertNotMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+
+    // Make the change work-in-progress again.
+    r = amendChange(changeId, "refs/for/master%wip");
+    r.assertOkStatus();
+    r.assertMessage(" [WIP]");
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+    assertUploadTag(r.getChange(), ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET);
+
+    // Can't use --wip and --ready together.
+    r = amendChange(changeId, "refs/for/master%wip,ready");
+    r.assertErrorStatus();
+
+    // Pushing directly to the branch removes the work-in-progress flag
+    String master = "refs/heads/master";
+    assertPushOk(pushHead(testRepo, master, false), master);
+    ChangeInfo result = Iterables.getOnlyElement(gApi.changes().query(changeId).get());
+    assertThat(result.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(result.workInProgress).isNull();
+  }
+
+  private void assertUploadTag(ChangeData cd, String expectedTag) throws Exception {
+    List<ChangeMessage> msgs = cd.messages();
+    assertThat(msgs).isNotEmpty();
+    assertThat(Iterables.getLast(msgs).getTag()).isEqualTo(expectedTag);
+  }
+
+  @Test
+  public void pushWorkInProgressChangeWhenNotOwner() throws Exception {
+    TestRepository<?> userRepo = cloneProject(project, user);
+    PushOneCommit.Result r =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master%wip");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id);
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Admin user trying to move from WIP to ready should succeed.
+    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    testRepo.reset("ps");
+    r = amendChange(r.getChangeId(), "refs/for/master%ready", user, testRepo);
+    r.assertOkStatus();
+
+    // Other user trying to move from WIP to WIP should succeed.
+    r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Push as change owner to move change from WIP to ready.
+    r = pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master%ready");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    // Admin user trying to move from ready to WIP should succeed.
+    GitUtil.fetch(testRepo, r.getPatchSet().getRefName() + ":ps");
+    testRepo.reset("ps");
+    r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
+    r.assertOkStatus();
+
+    // Other user trying to move from wip to wip should succeed.
+    r = amendChange(r.getChangeId(), "refs/for/master%wip", admin, testRepo);
+    r.assertOkStatus();
+
+    // Non owner, non admin and non project owner cannot flip wip bit:
+    TestAccount user2 = accountCreator.user2();
+    grant(
+        project, "refs/*", Permission.FORGE_COMMITTER, false, SystemGroupBackend.REGISTERED_USERS);
+    TestRepository<?> user2Repo = cloneProject(project, user2);
+    GitUtil.fetch(user2Repo, r.getPatchSet().getRefName() + ":ps");
+    user2Repo.reset("ps");
+    r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
+    r.assertErrorStatus(ReceiveConstants.ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
+
+    // Project owner trying to move from WIP to ready should succeed.
+    allow("refs/*", Permission.OWNER, SystemGroupBackend.REGISTERED_USERS);
+    r = amendChange(r.getChangeId(), "refs/for/master%ready", user2, user2Repo);
+    r.assertOkStatus();
   }
 
   @Test
@@ -418,6 +696,9 @@
     r.assertMessage(
         "Updated Changes:\n  "
             + canonicalWebUrl.get()
+            + "#/c/"
+            + project.get()
+            + "/+/"
             + r.getChange().getId()
             + " "
             + editInfo.commit.subject
@@ -452,7 +733,9 @@
     PushOneCommit push =
         pushFactory.create(
             db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "a.txt", "content");
-    PushOneCommit.Result r = push.to("refs/for/master/%m=my_test_message");
+    // %2C is comma; the value below tests that percent decoding happens after splitting.
+    // All three ways of representing space ("%20", "+", and "_" are also exercised.
+    PushOneCommit.Result r = push.to("refs/for/master/%m=my_test%20+_message%2Cm=");
     r.assertOkStatus();
 
     push =
@@ -474,12 +757,54 @@
       if (ri.isCurrent) {
         assertThat(ri.description).isEqualTo("new test message");
       } else {
-        assertThat(ri.description).isEqualTo("my test message");
+        assertThat(ri.description).isEqualTo("my test   message,m=");
       }
     }
   }
 
   @Test
+  public void pushForMasterWithPercentEncodedMessage() throws Exception {
+    // Exercise percent-encoding of UTF-8, underscores, and patterns reserved by git-rev-parse.
+    PushOneCommit.Result r =
+        pushTo(
+            "refs/for/master/%m="
+                + "Punctu%2E%2e%2Eation%7E%2D%40%7Bu%7D%20%7C%20%28%E2%95%AF%C2%B0%E2%96%A1%C2%B0"
+                + "%EF%BC%89%E2%95%AF%EF%B8%B5%20%E2%94%BB%E2%94%81%E2%94%BB%20%5E%5F%5E");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+    ChangeInfo ci = get(r.getChangeId(), MESSAGES, ALL_REVISIONS);
+    Collection<ChangeMessageInfo> changeMessages = ci.messages;
+    assertThat(changeMessages).hasSize(1);
+    for (ChangeMessageInfo cm : changeMessages) {
+      assertThat(cm.message)
+          .isEqualTo("Uploaded patch set 1.\nPunctu...ation~-@{u} | (╯°□°)╯︵ ┻━┻ ^_^");
+    }
+    Collection<RevisionInfo> revisions = ci.revisions.values();
+    assertThat(revisions).hasSize(1);
+    for (RevisionInfo ri : revisions) {
+      assertThat(ri.description).isEqualTo("Punctu...ation~-@{u} | (╯°□°)╯︵ ┻━┻ ^_^");
+    }
+  }
+
+  @Test
+  public void pushForMasterWithInvalidPercentEncodedMessage() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master/%m=not_percent_decodable_%%oops%20");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+    ChangeInfo ci = get(r.getChangeId(), MESSAGES, ALL_REVISIONS);
+    Collection<ChangeMessageInfo> changeMessages = ci.messages;
+    assertThat(changeMessages).hasSize(1);
+    for (ChangeMessageInfo cm : changeMessages) {
+      assertThat(cm.message).isEqualTo("Uploaded patch set 1.\nnot percent decodable %%oops%20");
+    }
+    Collection<RevisionInfo> revisions = ci.revisions.values();
+    assertThat(revisions).hasSize(1);
+    for (RevisionInfo ri : revisions) {
+      assertThat(ri.description).isEqualTo("not percent decodable %%oops%20");
+    }
+  }
+
+  @Test
   public void pushForMasterWithApprovals() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master/%l=Code-Review");
     r.assertOkStatus();
@@ -665,10 +990,7 @@
             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.");
+    r.assertErrorStatus("cannot add patch set to " + r.getChange().change().getChangeId() + ".");
   }
 
   @Test
@@ -794,7 +1116,7 @@
         pushFactory.create(
             db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "anotherContent");
     r = push.to("refs/for/master");
-    r.assertErrorStatus("not Signed-off-by author/committer/uploader in commit message footer");
+    r.assertErrorStatus("not Signed-off-by author/committer/uploader in message footer");
   }
 
   @Test
@@ -849,7 +1171,7 @@
 
   @Test
   public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
     PushOneCommit.Result rBase = pushTo("refs/heads/master");
     rBase.assertOkStatus();
 
@@ -866,6 +1188,9 @@
         GitUtil.pushHead(testRepo, "refs/for/foo%base=" + rBase.getCommit().name(), false, false);
     assertThat(pr.getMessages()).containsMatch("changes: .*new: 1.*done");
 
+    // BatchUpdate implementations differ in how they hook into progress monitors. We mostly just
+    // care that there is a new change.
+    assertThat(pr.getMessages()).containsMatch("changes: new: 1,( refs: 1)? done");
     assertTwoChangesWithSameRevision(r);
   }
 
@@ -985,8 +1310,8 @@
 
   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");
+    assertThat(GitUtil.getChangeId(testRepo, c)).isEmpty();
+    pushForReviewRejected(testRepo, "missing Change-Id in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
     pushForReviewOk(testRepo);
@@ -1010,10 +1335,10 @@
             + "\n"
             + "Change-Id: I10f98c2ef76e52e23aa23be5afeb71e40b350e86\n"
             + "Change-Id: Ie9a132e107def33bdd513b7854b50de911edba0a\n");
-    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "multiple Change-Id lines in commit message footer");
+    pushForReviewRejected(testRepo, "multiple Change-Id lines in message footer");
   }
 
   @Test
@@ -1029,10 +1354,10 @@
 
   private void testpushWithInvalidChangeId() throws Exception {
     createCommit(testRepo, "Message with invalid Change-Id\n\nChange-Id: X\n");
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
   }
 
   @Test
@@ -1053,19 +1378,19 @@
         "Message with invalid Change-Id\n"
             + "\n"
             + "Change-Id: I0000000000000000000000000000000000000000\n");
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "invalid Change-Id line format in commit message footer");
+    pushForReviewRejected(testRepo, "invalid Change-Id line format in 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");
+    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
-    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in commit message footer");
+    pushForReviewRejected(testRepo, "missing subject; Change-Id must be in message footer");
   }
 
   @Test
@@ -1198,7 +1523,7 @@
 
   @Test
   public void forcePushAbandonedChange() throws Exception {
-    grant(Permission.PUSH, project, "refs/*", true);
+    grant(project, "refs/*", Permission.PUSH, true);
     PushOneCommit push1 =
         pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r = push1.to("refs/for/master");
@@ -1294,7 +1619,7 @@
   @Test
   public void createChangeForMergedCommit() throws Exception {
     String master = "refs/heads/master";
-    grant(Permission.PUSH, project, master, true);
+    grant(project, master, Permission.PUSH, true);
 
     // Update master with a direct push.
     RevCommit c1 = testRepo.commit().message("Non-change 1").create();
@@ -1393,7 +1718,7 @@
   @Test
   public void mergedOptionWithExistingChangeInsertsPatchSet() throws Exception {
     String master = "refs/heads/master";
-    grant(Permission.PUSH, project, master, true);
+    grant(project, master, Permission.PUSH, true);
 
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
@@ -1418,14 +1743,379 @@
     String ref = "refs/for/master%merged";
     assertPushOk(pushHead(testRepo, ref, false), ref);
 
-    EnumSet<ListChangesOption> opts = EnumSet.of(ListChangesOption.ALL_REVISIONS);
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(opts);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(ALL_REVISIONS);
     assertThat(info.currentRevision).isEqualTo(c2.name());
     assertThat(info.revisions.keySet()).containsExactly(c1.name(), c2.name());
     // TODO(dborowitz): Fix ReceiveCommits to also auto-close the change.
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
   }
 
+  @Test
+  public void publishCommentsOnPushPublishesDraftsOnAllRevisions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String rev1 = r.getCommit().name();
+    CommentInfo c1 = addDraft(r.getChangeId(), rev1, newDraft(FILE_NAME, 1, "comment1"));
+    CommentInfo c2 = addDraft(r.getChangeId(), rev1, newDraft(FILE_NAME, 1, "comment2"));
+
+    r = amendChange(r.getChangeId());
+    String rev2 = r.getCommit().name();
+    CommentInfo c3 = addDraft(r.getChangeId(), rev2, newDraft(FILE_NAME, 1, "comment3"));
+
+    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    sender.clear();
+    amendChange(r.getChangeId(), "refs/for/master%publish-comments");
+
+    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    assertThat(comments.stream().map(c -> c.id)).containsExactly(c1.id, c2.id, c3.id);
+    assertThat(comments.stream().map(c -> c.message))
+        .containsExactly("comment1", "comment2", "comment3");
+    assertThat(getLastMessage(r.getChangeId())).isEqualTo("Uploaded patch set 3.\n\n(3 comments)");
+
+    List<String> messages =
+        sender.getMessages().stream()
+            .map(m -> m.body())
+            .sorted(Comparator.comparingInt(m -> m.contains("reexamine") ? 0 : 1))
+            .collect(toList());
+    assertThat(messages).hasSize(2);
+
+    assertThat(messages.get(0)).contains("Gerrit-MessageType: newpatchset");
+    assertThat(messages.get(0)).contains("I'd like you to reexamine a change");
+    assertThat(messages.get(0)).doesNotContain("Uploaded patch set 3");
+
+    assertThat(messages.get(1)).contains("Gerrit-MessageType: comment");
+    assertThat(messages.get(1))
+        .containsMatch(
+            Pattern.compile(
+                // A little weird that the comment email contains this text, but it's actually
+                // what's in the ChangeMessage. Really we should fuse the emails into one, but until
+                // then, this test documents the current behavior.
+                "Uploaded patch set 3\\.\n"
+                    + "\n"
+                    + "\\(3 comments\\)\\n.*"
+                    + "PS1, Line 1:.*"
+                    + "comment1\\n.*"
+                    + "PS1, Line 1:.*"
+                    + "comment2\\n.*"
+                    + "PS2, Line 1:.*"
+                    + "comment3\\n",
+                Pattern.DOTALL));
+  }
+
+  @Test
+  public void publishCommentsOnPushWithMessage() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String rev = r.getCommit().name();
+    addDraft(r.getChangeId(), rev, newDraft(FILE_NAME, 1, "comment1"));
+
+    r = amendChange(r.getChangeId(), "refs/for/master%publish-comments,m=The_message");
+
+    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    assertThat(comments.stream().map(c -> c.message)).containsExactly("comment1");
+    assertThat(getLastMessage(r.getChangeId()))
+        .isEqualTo("Uploaded patch set 2.\n\n(1 comment)\n\nThe message");
+  }
+
+  @Test
+  public void publishCommentsOnPushPublishesDraftsOnMultipleChanges() throws Exception {
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    List<RevCommit> commits = createChanges(2, "refs/for/master");
+    String id1 = byCommit(commits.get(0)).change().getKey().get();
+    String id2 = byCommit(commits.get(1)).change().getKey().get();
+    CommentInfo c1 = addDraft(id1, commits.get(0).name(), newDraft(FILE_NAME, 1, "comment1"));
+    CommentInfo c2 = addDraft(id2, commits.get(1).name(), newDraft(FILE_NAME, 1, "comment2"));
+
+    assertThat(getPublishedComments(id1)).isEmpty();
+    assertThat(getPublishedComments(id2)).isEmpty();
+
+    amendChanges(initialHead, commits, "refs/for/master%publish-comments");
+
+    Collection<CommentInfo> cs1 = getPublishedComments(id1);
+    assertThat(cs1.stream().map(c -> c.message)).containsExactly("comment1");
+    assertThat(cs1.stream().map(c -> c.id)).containsExactly(c1.id);
+    assertThat(getLastMessage(id1))
+        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
+
+    Collection<CommentInfo> cs2 = getPublishedComments(id2);
+    assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
+    assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
+    assertThat(getLastMessage(id2))
+        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
+  }
+
+  @Test
+  public void publishCommentsOnPushOnlyPublishesDraftsOnUpdatedChanges() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    String id1 = r1.getChangeId();
+    String id2 = r2.getChangeId();
+    addDraft(id1, r1.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
+    CommentInfo c2 = addDraft(id2, r2.getCommit().name(), newDraft(FILE_NAME, 1, "comment2"));
+
+    assertThat(getPublishedComments(id1)).isEmpty();
+    assertThat(getPublishedComments(id2)).isEmpty();
+
+    r2 = amendChange(id2, "refs/for/master%publish-comments");
+
+    assertThat(getPublishedComments(id1)).isEmpty();
+    assertThat(gApi.changes().id(id1).drafts()).hasSize(1);
+
+    Collection<CommentInfo> cs2 = getPublishedComments(id2);
+    assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
+    assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
+
+    assertThat(getLastMessage(id1)).doesNotMatch("[Cc]omment");
+    assertThat(getLastMessage(id2)).isEqualTo("Uploaded patch set 2.\n\n(1 comment)");
+  }
+
+  @Test
+  public void publishCommentsOnPushWithPreference() throws Exception {
+    PushOneCommit.Result r = createChange();
+    addDraft(r.getChangeId(), r.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
+    r = amendChange(r.getChangeId());
+
+    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
+
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.publishCommentsOnPush = true;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+
+    r = amendChange(r.getChangeId());
+    assertThat(getPublishedComments(r.getChangeId()).stream().map(c -> c.message))
+        .containsExactly("comment1");
+  }
+
+  @Test
+  public void publishCommentsOnPushOverridingPreference() throws Exception {
+    PushOneCommit.Result r = createChange();
+    addDraft(r.getChangeId(), r.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
+
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.publishCommentsOnPush = true;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+
+    r = amendChange(r.getChangeId(), "refs/for/master%no-publish-comments");
+
+    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
+  }
+
+  @Test
+  public void pushWithDraftOptionIsDisabledPerDefault() throws Exception {
+    for (String ref : ImmutableSet.of("refs/drafts/master", "refs/for/master%draft")) {
+      PushOneCommit.Result r = pushTo(ref);
+      r.assertErrorStatus();
+      r.assertMessage("draft workflow is disabled");
+    }
+  }
+
+  @GerritConfig(name = "change.allowDrafts", value = "true")
+  @Test
+  public void pushDraftGetsPrivateChange() throws Exception {
+    String changeId1 = createChange("refs/drafts/master").getChangeId();
+    String changeId2 = createChange("refs/for/master%draft").getChangeId();
+
+    ChangeInfo info1 = gApi.changes().id(changeId1).get();
+    ChangeInfo info2 = gApi.changes().id(changeId2).get();
+
+    assertThat(info1.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info2.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(info1.isPrivate).isEqualTo(true);
+    assertThat(info2.isPrivate).isEqualTo(true);
+    assertThat(info1.revisions).hasSize(1);
+    assertThat(info2.revisions).hasSize(1);
+  }
+
+  @GerritConfig(name = "change.allowDrafts", value = "true")
+  @Sandboxed
+  @Test
+  public void pushWithDraftOptionToExistingNewChangeGetsChangeEdit() throws Exception {
+    String changeId = createChange().getChangeId();
+    EditInfoSubject.assertThat(getEdit(changeId)).isAbsent();
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    ChangeStatus originalChangeStatus = changeInfo.status;
+
+    PushOneCommit.Result result = amendChange(changeId, "refs/drafts/master");
+    result.assertOkStatus();
+
+    changeInfo = gApi.changes().id(changeId).get();
+    assertThat(changeInfo.status).isEqualTo(originalChangeStatus);
+    assertThat(changeInfo.isPrivate).isNull();
+    assertThat(changeInfo.revisions).hasSize(1);
+
+    EditInfoSubject.assertThat(getEdit(changeId)).isPresent();
+  }
+
+  @GerritConfig(name = "receive.maxBatchCommits", value = "2")
+  @Test
+  public void maxBatchCommits() throws Exception {
+    testMaxBatchCommits();
+  }
+
+  @GerritConfig(name = "receive.maxBatchCommits", value = "2")
+  @Test
+  public void maxBatchCommitsWithDefaultValidator() throws Exception {
+    TestValidator validator = new TestValidator();
+    RegistrationHandle handle = commitValidators.add(validator);
+    try {
+      testMaxBatchCommits();
+    } finally {
+      handle.remove();
+    }
+  }
+
+  @GerritConfig(name = "receive.maxBatchCommits", value = "2")
+  @Test
+  public void maxBatchCommitsWithValidateAllCommitsValidator() throws Exception {
+    TestValidator validator = new TestValidator(true);
+    RegistrationHandle handle = commitValidators.add(validator);
+    try {
+      testMaxBatchCommits();
+    } finally {
+      handle.remove();
+    }
+  }
+
+  private void testMaxBatchCommits() throws Exception {
+    List<RevCommit> commits = new ArrayList<>();
+    commits.addAll(initChanges(2));
+    String master = "refs/heads/master";
+    assertPushOk(pushHead(testRepo, master), master);
+
+    commits.addAll(initChanges(3));
+    assertPushRejected(pushHead(testRepo, master), master, "too many commits");
+
+    grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
+    PushResult r =
+        pushHead(testRepo, master, false, false, ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+    assertPushOk(r, master);
+
+    // No open changes; branch was advanced.
+    String q = commits.stream().map(ObjectId::name).collect(joining(" OR commit:", "commit:", ""));
+    assertThat(gApi.changes().query(q).get()).isEmpty();
+    assertThat(gApi.projects().name(project.get()).branch(master).get().revision)
+        .isEqualTo(Iterables.getLast(commits).name());
+  }
+
+  private static class TestValidator implements CommitValidationListener {
+    private final AtomicInteger count = new AtomicInteger();
+    private final boolean validateAll;
+
+    TestValidator(boolean validateAll) {
+      this.validateAll = validateAll;
+    }
+
+    TestValidator() {
+      this(false);
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) {
+      count.incrementAndGet();
+      return Collections.emptyList();
+    }
+
+    @Override
+    public boolean shouldValidateAllCommits() {
+      return validateAll;
+    }
+
+    public int count() {
+      return count.get();
+    }
+  }
+
+  @Test
+  public void skipValidation() throws Exception {
+    String master = "refs/heads/master";
+    TestValidator validator = new TestValidator();
+    RegistrationHandle handle = commitValidators.add(validator);
+    RegistrationHandle handle2 = null;
+
+    try {
+      // Validation listener is called on normal push
+      PushOneCommit push =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r = push.to(master);
+      r.assertOkStatus();
+      assertThat(validator.count()).isEqualTo(1);
+
+      // Push is rejected and validation listener is not called when not allowed
+      // to use skip option
+      PushOneCommit push2 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
+      push2.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+      r = push2.to(master);
+      r.assertErrorStatus("skip validation not permitted for " + master);
+      assertThat(validator.count()).isEqualTo(1);
+
+      // Validation listener is not called when skip option is used
+      grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
+      PushOneCommit push3 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
+      push3.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+      r = push3.to(master);
+      r.assertOkStatus();
+      assertThat(validator.count()).isEqualTo(1);
+
+      // Validation listener that needs to validate all commits gets called even
+      // when the skip option is used.
+      TestValidator validator2 = new TestValidator(true);
+      handle2 = commitValidators.add(validator2);
+      PushOneCommit push4 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
+      push4.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+      r = push4.to(master);
+      r.assertOkStatus();
+      // First listener was not called; its count remains the same.
+      assertThat(validator.count()).isEqualTo(1);
+      // Second listener was called.
+      assertThat(validator2.count()).isEqualTo(1);
+    } finally {
+      handle.remove();
+      if (handle2 != null) {
+        handle2.remove();
+      }
+    }
+  }
+
+  @Test
+  public void pushToPublishMagicBranchIsAllowed() throws Exception {
+    // Push to "refs/publish/*" will be a synonym of "refs/for/*".
+    createChange("refs/publish/master");
+    PushOneCommit.Result result = pushTo("refs/publish/master");
+    result.assertOkStatus();
+    assertThat(result.getMessage())
+        .endsWith("Pushing to refs/publish/* is deprecated, use refs/for/* instead.\n");
+  }
+
+  private DraftInput newDraft(String path, int line, String message) {
+    DraftInput d = new DraftInput();
+    d.path = path;
+    d.side = Side.REVISION;
+    d.line = line;
+    d.message = message;
+    d.unresolved = true;
+    return d;
+  }
+
+  private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
+    return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
+  }
+
+  private Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).comments().values().stream()
+        .flatMap(cs -> cs.stream())
+        .collect(toList());
+  }
+
+  private String getLastMessage(String changeId) throws Exception {
+    return Streams.findLast(
+            gApi.changes().id(changeId).get(MESSAGES).messages.stream().map(m -> m.message))
+        .get();
+  }
+
   private void assertThatUserIsOnlyReviewer(ChangeInfo ci, TestAccount reviewer) {
     assertThat(ci.reviewers).isNotNull();
     assertThat(ci.reviewers.keySet()).containsExactly(ReviewerState.REVIEWER);
@@ -1464,11 +2154,21 @@
   }
 
   private List<RevCommit> createChanges(int n, String refsFor) throws Exception {
-    return createChanges(n, refsFor, ImmutableList.<String>of());
+    return createChanges(n, refsFor, ImmutableList.of());
   }
 
   private List<RevCommit> createChanges(int n, String refsFor, List<String> footerLines)
       throws Exception {
+    List<RevCommit> commits = initChanges(n, footerLines);
+    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
+    return commits;
+  }
+
+  private List<RevCommit> initChanges(int n) throws Exception {
+    return initChanges(n, ImmutableList.of());
+  }
+
+  private List<RevCommit> initChanges(int n, List<String> footerLines) throws Exception {
     List<RevCommit> commits = new ArrayList<>(n);
     for (int i = 1; i <= n; i++) {
       String msg = "Change " + i;
@@ -1488,7 +2188,6 @@
       testRepo.getRevWalk().parseBody(c);
       commits.add(c);
     }
-    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
     return commits;
   }
 
@@ -1553,4 +2252,15 @@
       assertThat(refUpdate.getMessage()).contains(expectedMessage);
     }
   }
+
+  private void grantSkipValidation(Project.NameKey project, String ref, AccountGroup.UUID groupUuid)
+      throws Exception {
+    // See SKIP_VALIDATION implementation in default permission backend.
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    Util.allow(config, Permission.FORGE_AUTHOR, groupUuid, ref);
+    Util.allow(config, Permission.FORGE_COMMITTER, groupUuid, ref);
+    Util.allow(config, Permission.FORGE_SERVER, groupUuid, ref);
+    Util.allow(config, Permission.PUSH_MERGE, groupUuid, "refs/for/" + ref);
+    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 42463c7..c89ad5e 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -25,7 +26,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -87,8 +90,8 @@
       SubmitType submitType)
       throws Exception {
     Project.NameKey project = createProject(name, parent, createEmptyCommit, submitType);
-    grant(Permission.PUSH, project, "refs/heads/*");
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
+    grant(project, "refs/heads/*", Permission.PUSH);
+    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
     return cloneProject(project);
   }
 
@@ -141,6 +144,31 @@
     return pushChangeTo(repo, "refs/heads/" + branch, "some change", "");
   }
 
+  protected ObjectId pushChangesTo(TestRepository<?> repo, String branch, int numChanges)
+      throws Exception {
+    for (int i = 0; i < numChanges; i++) {
+      repo.branch("HEAD")
+          .commit()
+          .insertChangeId()
+          .message("Message " + i)
+          .add(name("file"), "content" + i)
+          .create();
+    }
+    String remoteBranch = "refs/heads/" + branch;
+    Iterable<PushResult> res =
+        repo.git()
+            .push()
+            .setRemote("origin")
+            .setRefSpecs(new RefSpec("HEAD:" + remoteBranch))
+            .call();
+    List<Status> status =
+        StreamSupport.stream(res.spliterator(), false)
+            .map(r -> r.getRemoteUpdate(remoteBranch).getStatus())
+            .collect(toList());
+    assertThat(status).containsExactly(Status.OK);
+    return Iterables.getLast(res).getRemoteUpdate(remoteBranch).getNewObjectId();
+  }
+
   protected void allowSubmoduleSubscription(
       String submodule, String subBranch, String superproject, String superBranch, boolean match)
       throws Exception {
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
deleted file mode 100644
index f2dc8d5..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.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.acceptance.git;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
-
-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 org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class DraftChangeBlockedIT extends AbstractDaemonTest {
-
-  @Before
-  public void setUp() throws Exception {
-    block(Permission.PUSH, ANONYMOUS_USERS, "refs/drafts/*");
-  }
-
-  @Test
-  public void pushDraftChange_Blocked() throws Exception {
-    // create draft by pushing to 'refs/drafts/'
-    PushOneCommit.Result r = pushTo("refs/drafts/master");
-    r.assertErrorStatus("cannot upload drafts");
-  }
-
-  @Test
-  public void pushDraftChangeMagic_Blocked() throws Exception {
-    // create draft by using 'draft' option
-    PushOneCommit.Result r = pushTo("refs/for/master%draft");
-    r.assertErrorStatus("cannot upload drafts");
-  }
-}
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 da8302b..ffa4b60 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
@@ -57,7 +57,7 @@
   @Test
   public void forcePushAllowed() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-    grant(Permission.PUSH, project, "refs/*", true);
+    grant(project, "refs/*", Permission.PUSH, true);
     PushOneCommit push1 =
         pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
@@ -82,19 +82,19 @@
 
   @Test
   public void deleteNotAllowedWithOnlyPushPermission() throws Exception {
-    grant(Permission.PUSH, project, "refs/*", false);
+    grant(project, "refs/*", Permission.PUSH, false);
     assertDeleteRef(REJECTED_OTHER_REASON);
   }
 
   @Test
   public void deleteAllowedWithForcePushPermission() throws Exception {
-    grant(Permission.PUSH, project, "refs/*", true);
+    grant(project, "refs/*", Permission.PUSH, true);
     assertDeleteRef(OK);
   }
 
   @Test
   public void deleteAllowedWithDeletePermission() throws Exception {
-    grant(Permission.DELETE, project, "refs/*", true);
+    grant(project, "refs/*", Permission.DELETE, true);
     assertDeleteRef(OK);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/GitOverHttpServletIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/GitOverHttpServletIT.java
new file mode 100644
index 0000000..3d6d16a
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/GitOverHttpServletIT.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.gerrit.audit.AuditEvent;
+import com.google.gerrit.audit.AuditService;
+import com.google.gerrit.testutil.FakeAuditService;
+import com.google.inject.AbstractModule;
+import java.util.Collections;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.Description;
+
+public class GitOverHttpServletIT extends AbstractPushForReview {
+
+  @Override
+  protected void beforeTest(Description description) throws Exception {
+    testSysModule =
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(AuditService.class).to(FakeAuditService.class);
+          }
+        };
+    super.beforeTest(description);
+  }
+
+  @Before
+  public void beforeEach() throws Exception {
+    CredentialsProvider.setDefault(
+        new UsernamePasswordCredentialsProvider(admin.username, admin.httpPassword));
+    selectProtocol(AbstractPushForReview.Protocol.HTTP);
+    auditService.clearEvents();
+  }
+
+  @Test
+  public void receivePackAuditEventLog() throws Exception {
+    testRepo
+        .git()
+        .push()
+        .setRemote("origin")
+        .setRefSpecs(new RefSpec("HEAD:refs/for/master"))
+        .call();
+
+    // Git smart protocol makes two requests:
+    // https://github.com/git/git/blob/master/Documentation/technical/http-protocol.txt
+    assertThat(auditService.auditEvents.size()).isEqualTo(2);
+
+    AuditEvent e = auditService.auditEvents.get(1);
+    assertThat(e.who.getAccountId()).isEqualTo(admin.id);
+    assertThat(e.what).endsWith("/git-receive-pack");
+    assertThat(e.params).isEmpty();
+  }
+
+  @Test
+  public void uploadPackAuditEventLog() throws Exception {
+    testRepo.git().fetch().call();
+
+    assertThat(auditService.auditEvents.size()).isEqualTo(1);
+
+    AuditEvent e = auditService.auditEvents.get(0);
+    assertThat(e.who.toString()).isEqualTo("ANONYMOUS");
+    assertThat(e.params.get("service"))
+        .containsExactlyElementsIn(Collections.singletonList("git-upload-pack"));
+    assertThat(e.what).endsWith("service=git-upload-pack");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index b900cc7..4dfd7ac 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -17,43 +17,48 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
 
+import com.google.common.base.Predicates;
+import com.google.common.collect.ImmutableList;
 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.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
 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.changes.DraftInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Patch;
 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.Sequences;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.ReceiveCommitsAdvertiseRefsHook;
-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.git.receive.ReceiveCommitsAdvertiseRefsHook;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.testutil.DisabledReviewDb;
+import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Predicate;
+import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -65,17 +70,10 @@
 
 @NoHttpd
 public class RefAdvertisementIT extends AbstractDaemonTest {
-  @Inject private ProjectControl.GenericFactory projectControlFactory;
-
-  @Inject @Nullable private SearchingChangeCacheImpl changeCache;
-
-  @Inject private TagCache tagCache;
-
-  @Inject private Provider<CurrentUser> userProvider;
-
+  @Inject private VisibleRefFilter.Factory refFilterFactory;
   @Inject private ChangeNoteUtil noteUtil;
-
   @Inject @AnonymousCowardName private String anonymousCowardName;
+  @Inject private AllUsersName allUsersName;
 
   private AccountGroup.UUID admins;
 
@@ -90,20 +88,28 @@
 
   @Before
   public void setUp() throws Exception {
-    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID();
+    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID();
     setUpPermissions();
     setUpChanges();
   }
 
   private void setUpPermissions() throws Exception {
-    // Remove read permissions for all users besides admin. This method is
-    // idempotent, so is safe to call on every test setup.
+    // Remove read permissions for all users besides admin. 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);
     }
     Util.allow(pc, Permission.READ, admins, "refs/*");
     saveProjectConfig(allProjects, pc);
+
+    // Remove all read permissions on All-Users. This method is idempotent, so is safe to call on
+    // every test setup.
+    pc = projectCache.checkedGet(allUsersName).getConfig();
+    for (AccessSection sec : pc.getAccessSections()) {
+      sec.removePermission(Permission.READ);
+    }
+    saveProjectConfig(allUsersName, pc);
   }
 
   private static String changeRefPrefix(Change.Id id) {
@@ -116,7 +122,7 @@
 
     // First 2 changes are merged, which means the tags pointing to them are
     // visible.
-    allow(Permission.SUBMIT, admins, "refs/for/refs/heads/*");
+    allow("refs/for/refs/heads/*", Permission.SUBMIT, admins);
     PushOneCommit.Result mr =
         pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%submit");
     mr.assertOkStatus();
@@ -180,8 +186,8 @@
 
   @Test
   public void uploadPackAllRefsVisibleWithRefsMetaConfig() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/*");
-    allow(Permission.READ, REGISTERED_USERS, RefNames.REFS_CONFIG);
+    allow("refs/*", Permission.READ, REGISTERED_USERS);
+    allow(RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
 
     assertUploadPackRefs(
         "HEAD",
@@ -202,8 +208,8 @@
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleIncludingHead() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
 
     setApiUser(user);
     assertUploadPackRefs(
@@ -218,8 +224,8 @@
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleNotIncludingHead() throws Exception {
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
 
     setApiUser(user);
     assertUploadPackRefs(
@@ -236,8 +242,7 @@
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
 
     Change c = notesFactory.createChecked(db, project, c1.getId()).getChange();
     String changeId = c.getKey().get();
@@ -262,11 +267,44 @@
   }
 
   @Test
+  public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    allow("refs/*", Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS);
+
+    Change change1 = notesFactory.createChecked(db, project, c1.getId()).getChange();
+    String changeId1 = change1.getKey().get();
+    Change change2 = notesFactory.createChecked(db, project, c2.getId()).getChange();
+    String changeId2 = change2.getKey().get();
+
+    // Admin's edit on change1 is visible.
+    setApiUser(admin);
+    gApi.changes().id(changeId1).edit().create();
+
+    // Admin's edit on change2 is not visible since user cannot see the change.
+    gApi.changes().id(changeId2).edit().create();
+
+    // User's edit is visible.
+    setApiUser(user);
+    gApi.changes().id(changeId1).edit().create();
+
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/master",
+        "refs/tags/master-tag",
+        "refs/users/00/1000000/edit-" + c1.getId() + "/1",
+        "refs/users/01/1000001/edit-" + c1.getId() + "/1");
+  }
+
+  @Test
   public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     try {
-      deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-      allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+      deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
+      allow("refs/heads/branch", Permission.READ, REGISTERED_USERS);
 
       String changeId = c1.change().getKey().get();
       setApiUser(admin);
@@ -296,62 +334,14 @@
   }
 
   @Test
-  public void uploadPackDraftRefs() 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 c5 = br.getChange().getId();
-    String r5 = changeRefPrefix(c5);
-
-    // Only admin can see admin's draft change (5).
-    setApiUser(admin);
-    assertUploadPackRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        r4 + "1",
-        r4 + "meta",
-        r5 + "1",
-        r5 + "meta",
-        "refs/heads/branch",
-        "refs/heads/master",
-        RefNames.REFS_CONFIG,
-        "refs/tags/branch-tag",
-        "refs/tags/master-tag");
-
-    // user can't.
-    setApiUser(user);
-    assertUploadPackRefs(
-        "HEAD",
-        r1 + "1",
-        r1 + "meta",
-        r2 + "1",
-        r2 + "meta",
-        r3 + "1",
-        r3 + "meta",
-        r4 + "1",
-        r4 + "meta",
-        "refs/heads/branch",
-        "refs/heads/master",
-        "refs/tags/branch-tag",
-        "refs/tags/master-tag");
-  }
-
-  @Test
   public void uploadPackNoSearchingChangeCacheImpl() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/*");
+    allow("refs/heads/*", Permission.READ, REGISTERED_USERS);
 
     setApiUser(user);
     try (Repository repo = repoManager.openRepository(project)) {
       assertRefs(
           repo,
-          new VisibleRefFilter(tagCache, notesFactory, null, repo, projectControl(), db, true),
+          refFilterFactory.create(projectCache.get(project), repo),
           // Can't use stored values from the index so DB must be enabled.
           false,
           "HEAD",
@@ -375,12 +365,12 @@
     assume().that(notesMigration.readChangeSequence()).isTrue();
     try (Repository repo = repoManager.openRepository(allProjects)) {
       setApiUser(user);
-      assertRefs(repo, newFilter(db, repo, allProjects), true);
+      assertRefs(repo, newFilter(repo, allProjects), true);
 
       allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
       try {
         setApiUser(user);
-        assertRefs(repo, newFilter(db, repo, allProjects), true, "refs/sequences/changes");
+        assertRefs(repo, newFilter(repo, allProjects), true, "refs/sequences/changes");
       } finally {
         removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
       }
@@ -404,8 +394,8 @@
 
   @Test
   public void receivePackRespectsVisibilityOfOpenChanges() throws Exception {
-    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
     setApiUser(user);
 
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 1));
@@ -470,6 +460,176 @@
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c4, 1));
   }
 
+  @Test
+  public void advertisedReferencesDontShowUserBranchWithoutRead() throws Exception {
+    TestRepository<?> userTestRepository = cloneProject(allUsersName, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getUserRefs(git)).isEmpty();
+    }
+  }
+
+  @Test
+  public void advertisedReferencesOmitUserBranchesOfOtherUsers() throws Exception {
+    allow(allUsersName, RefNames.REFS_USERS + "*", Permission.READ, REGISTERED_USERS);
+    TestRepository<?> userTestRepository = cloneProject(allUsersName, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getUserRefs(git))
+          .containsExactly(RefNames.REFS_USERS_SELF, RefNames.refsUsers(user.id));
+    }
+  }
+
+  @Test
+  public void advertisedReferencesIncludeAllUserBranchesWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    try {
+      TestRepository<?> userTestRepository = cloneProject(allUsersName, user);
+      try (Git git = userTestRepository.git()) {
+        assertThat(getUserRefs(git))
+            .containsExactly(
+                RefNames.REFS_USERS_SELF,
+                RefNames.refsUsers(user.id),
+                RefNames.refsUsers(admin.id));
+      }
+    } finally {
+      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    }
+  }
+
+  @Test
+  public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
+    allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      String change3RefName = c3.currentPatchSet().getRefName();
+      assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
+      assertThat(getRefs(git)).doesNotContain(change3RefName);
+    }
+  }
+
+  @Test
+  public void advertisedReferencesIncludePrivateChangesWhenAllRefsMayBeRead() throws Exception {
+    allow("refs/*", Permission.READ, REGISTERED_USERS);
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      String change3RefName = c3.currentPatchSet().getRefName();
+      assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
+      assertThat(getRefs(git)).contains(change3RefName);
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void advertisedReferencesOmitDraftCommentRefsOfOtherUsers() throws Exception {
+    assume().that(notesMigration.commitChangeWrites()).isTrue();
+
+    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
+    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
+
+    setApiUser(user);
+    DraftInput draftInput = new DraftInput();
+    draftInput.line = 1;
+    draftInput.message = "nit: trailing whitespace";
+    draftInput.path = Patch.COMMIT_MSG;
+    gApi.changes().id(c3.getId().get()).current().createDraft(draftInput);
+    String draftCommentRef = RefNames.refsDraftComments(c3.getId(), user.id);
+
+    // user can see the draft comment ref of the own draft comment
+    assertThat(lsRemote(allUsersName, user)).contains(draftCommentRef);
+
+    // user2 can't see the draft comment ref of user's draft comment
+    assertThat(lsRemote(allUsersName, accountCreator.user2())).doesNotContain(draftCommentRef);
+  }
+
+  @Test
+  @Sandboxed
+  public void advertisedReferencesOmitStarredChangesRefsOfOtherUsers() throws Exception {
+    assume().that(notesMigration.commitChangeWrites()).isTrue();
+
+    allow(project, "refs/*", Permission.READ, REGISTERED_USERS);
+    allow(allUsersName, "refs/*", Permission.READ, REGISTERED_USERS);
+
+    setApiUser(user);
+    gApi.accounts().self().starChange(c3.getId().toString());
+    String starredChangesRef = RefNames.refsStarredChanges(c3.getId(), user.id);
+
+    // user can see the starred changes ref of the own star
+    assertThat(lsRemote(allUsersName, user)).contains(starredChangesRef);
+
+    // user2 can't see the starred changes ref of admin's star
+    assertThat(lsRemote(allUsersName, accountCreator.user2())).doesNotContain(starredChangesRef);
+  }
+
+  @Test
+  public void hideMetadata() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    try {
+      // create change
+      TestRepository<?> allUsersRepo = cloneProject(allUsersName);
+      fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userRef");
+      allUsersRepo.reset("userRef");
+      PushOneCommit.Result mr =
+          pushFactory
+              .create(db, admin.getIdent(), allUsersRepo)
+              .to("refs/for/" + RefNames.REFS_USERS_SELF);
+      mr.assertOkStatus();
+
+      List<String> expectedNonMetaRefs =
+          ImmutableList.of(
+              RefNames.REFS_USERS_SELF,
+              RefNames.refsUsers(admin.id),
+              RefNames.refsUsers(user.id),
+              RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS,
+              RefNames.REFS_EXTERNAL_IDS,
+              RefNames.REFS_CONFIG);
+
+      List<String> expectedMetaRefs =
+          new ArrayList<>(ImmutableList.of(mr.getPatchSetId().toRefName()));
+      if (NoteDbMode.get() != NoteDbMode.OFF) {
+        expectedMetaRefs.add(changeRefPrefix(mr.getChange().getId()) + "meta");
+      }
+
+      List<String> expectedAllRefs = new ArrayList<>(expectedNonMetaRefs);
+      expectedAllRefs.addAll(expectedMetaRefs);
+
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        Map<String, Ref> all = repo.getAllRefs();
+
+        VisibleRefFilter filter = refFilterFactory.create(projectCache.get(allUsersName), repo);
+        assertThat(filter.filter(all, false).keySet()).containsExactlyElementsIn(expectedAllRefs);
+
+        assertThat(filter.setShowMetadata(false).filter(all, false).keySet())
+            .containsExactlyElementsIn(expectedNonMetaRefs);
+      }
+    } finally {
+      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    }
+  }
+
+  private List<String> lsRemote(Project.NameKey p, TestAccount a) throws Exception {
+    TestRepository<?> testRepository = cloneProject(p, a);
+    try (Git git = testRepository.git()) {
+      return git.lsRemote().call().stream().map(Ref::getName).collect(toList());
+    }
+  }
+
+  private List<String> getRefs(Git git) throws Exception {
+    return getRefs(git, Predicates.alwaysTrue());
+  }
+
+  private List<String> getUserRefs(Git git) throws Exception {
+    return getRefs(git, RefNames::isRefsUsers);
+  }
+
+  private List<String> getRefs(Git git, Predicate<String> predicate) throws Exception {
+    return git.lsRemote().call().stream().map(Ref::getName).filter(predicate).collect(toList());
+  }
+
   /**
    * Assert that refs seen by a non-admin user match expected.
    *
@@ -481,17 +641,7 @@
   private void assertUploadPackRefs(String... expectedWithMeta) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       assertRefs(
-          repo,
-          new VisibleRefFilter(
-              tagCache,
-              notesFactory,
-              changeCache,
-              repo,
-              projectControl(),
-              new DisabledReviewDb(),
-              true),
-          true,
-          expectedWithMeta);
+          repo, refFilterFactory.create(projectCache.get(project), repo), true, expectedWithMeta);
     }
   }
 
@@ -500,7 +650,7 @@
       throws Exception {
     List<String> expected = new ArrayList<>(expectedWithMeta.length);
     for (String r : expectedWithMeta) {
-      if (notesMigration.writeChanges() || !r.endsWith(RefNames.META_SUFFIX)) {
+      if (notesMigration.commitChangeWrites() || !r.endsWith(RefNames.META_SUFFIX)) {
         expected.add(r);
       }
     }
@@ -527,20 +677,8 @@
     }
   }
 
-  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);
+  private VisibleRefFilter newFilter(Repository repo, Project.NameKey project) {
+    return refFilterFactory.create(projectCache.get(project), repo);
   }
 
   private static ObjectId obj(ChangeData cd, int psNum) throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
new file mode 100644
index 0000000..4c47428
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -0,0 +1,198 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.CREATE;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE;
+import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE_NONFASTFORWARD;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.events.RefReceivedEvent;
+import com.google.gerrit.server.git.validators.RefOperationValidationListener;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.Test;
+
+public class RefOperationValidationIT extends AbstractDaemonTest {
+  private static final String TEST_REF = "refs/heads/protected";
+
+  @Inject DynamicSet<RefOperationValidationListener> validators;
+
+  private class TestRefValidator implements RefOperationValidationListener, AutoCloseable {
+    private final ReceiveCommand.Type rejectType;
+    private final String rejectRef;
+    private final RegistrationHandle handle;
+
+    public TestRefValidator(ReceiveCommand.Type rejectType) {
+      this.rejectType = rejectType;
+      this.rejectRef = TEST_REF;
+      this.handle = validators.add(this);
+    }
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+        throws ValidationException {
+      if (refEvent.getRefName().equals(rejectRef)
+          && refEvent.command.getType().equals(rejectType)) {
+        throw new ValidationException(rejectType.name());
+      }
+      return Collections.emptyList();
+    }
+
+    @Override
+    public void close() throws Exception {
+      handle.remove();
+    }
+  }
+
+  @Test
+  public void rejectRefCreation() throws Exception {
+    try (TestRefValidator validator = new TestRefValidator(CREATE)) {
+      gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+      assert_().fail("expected exception");
+    } catch (RestApiException expected) {
+      assertThat(expected).hasMessageThat().contains(CREATE.name());
+    }
+  }
+
+  @Test
+  public void rejectRefCreationByPush() throws Exception {
+    try (TestRefValidator validator = new TestRefValidator(CREATE)) {
+      grant(project, "refs/*", Permission.PUSH, true);
+      PushOneCommit push1 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r1 = push1.to("refs/heads/master");
+      r1.assertOkStatus();
+      PushOneCommit.Result r2 = push1.to(TEST_REF);
+      r2.assertErrorStatus(CREATE.name());
+    }
+  }
+
+  @Test
+  public void rejectRefDeletion() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+    try (TestRefValidator validator = new TestRefValidator(DELETE)) {
+      gApi.projects().name(project.get()).branch(TEST_REF).delete();
+      assert_().fail("expected exception");
+    } catch (RestApiException expected) {
+      assertThat(expected).hasMessageThat().contains(DELETE.name());
+    }
+  }
+
+  @Test
+  public void rejectRefDeletionByPush() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+    grant(project, "refs/*", Permission.DELETE, true);
+    try (TestRefValidator validator = new TestRefValidator(DELETE)) {
+      PushResult result = deleteRef(testRepo, TEST_REF);
+      RemoteRefUpdate refUpdate = result.getRemoteUpdate(TEST_REF);
+      assertThat(refUpdate.getMessage()).contains(DELETE.name());
+    }
+  }
+
+  @Test
+  public void rejectRefUpdateFastForward() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+    try (TestRefValidator validator = new TestRefValidator(UPDATE)) {
+      grant(project, "refs/*", Permission.PUSH, true);
+      PushOneCommit push1 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r1 = push1.to(TEST_REF);
+      r1.assertErrorStatus(UPDATE.name());
+    }
+  }
+
+  @Test
+  public void rejectRefUpdateNonFastForward() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+    try (TestRefValidator validator = new TestRefValidator(UPDATE_NONFASTFORWARD)) {
+      ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+      grant(project, "refs/*", Permission.PUSH, true);
+      PushOneCommit push1 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r1 = push1.to(TEST_REF);
+      r1.assertOkStatus();
+
+      // Reset HEAD to initial so the new change is a non-fast forward
+      RefUpdate ru = repo().updateRef(HEAD);
+      ru.setNewObjectId(initial);
+      assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+
+      PushOneCommit push2 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
+      push2.setForce(true);
+      PushOneCommit.Result r2 = push2.to(TEST_REF);
+      r2.assertErrorStatus(UPDATE_NONFASTFORWARD.name());
+    }
+  }
+
+  @Test
+  public void rejectRefUpdateNonFastForwardToExistingCommit() throws Exception {
+    gApi.projects().name(project.get()).branch(TEST_REF).create(new BranchInput());
+
+    try (TestRefValidator validator = new TestRefValidator(UPDATE_NONFASTFORWARD)) {
+      grant(project, "refs/*", Permission.PUSH, true);
+      PushOneCommit push1 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
+      PushOneCommit.Result r1 = push1.to("refs/heads/master");
+      r1.assertOkStatus();
+      ObjectId push1Id = r1.getCommit();
+
+      PushOneCommit push2 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change2", "b.txt", "content");
+      PushOneCommit.Result r2 = push2.to("refs/heads/master");
+      r2.assertOkStatus();
+      ObjectId push2Id = r2.getCommit();
+
+      RefUpdate ru = repo().updateRef(HEAD);
+      ru.setNewObjectId(push1Id);
+      assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+
+      PushOneCommit push3 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change3", "c.txt", "content");
+      PushOneCommit.Result r3 = push3.to(TEST_REF);
+      r3.assertOkStatus();
+
+      ru = repo().updateRef(HEAD);
+      ru.setNewObjectId(push2Id);
+      assertThat(ru.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+
+      PushOneCommit push4 =
+          pushFactory.create(db, admin.getIdent(), testRepo, "change4", "d.txt", "content");
+      push4.setForce(true);
+      PushOneCommit.Result r4 = push4.to(TEST_REF);
+      r4.assertErrorStatus(UPDATE_NONFASTFORWARD.name());
+    }
+  }
+}
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 a685141..afc81e5 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
@@ -53,7 +53,7 @@
 
   @Test
   public void submitOnPush() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
     PushOneCommit.Result r = pushTo("refs/for/master%submit");
     r.assertOkStatus();
     r.assertChange(Change.Status.MERGED, null, admin);
@@ -63,9 +63,9 @@
 
   @Test
   public void submitOnPushWithTag() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
-    grant(Permission.CREATE, project, "refs/tags/*");
-    grant(Permission.PUSH, project, "refs/tags/*");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    grant(project, "refs/tags/*", Permission.CREATE);
+    grant(project, "refs/tags/*", Permission.PUSH);
     PushOneCommit.Tag tag = new PushOneCommit.Tag("v1.0");
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     push.setTag(tag);
@@ -79,8 +79,8 @@
 
   @Test
   public void submitOnPushWithAnnotatedTag() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
-    grant(Permission.PUSH, project, "refs/tags/*");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    grant(project, "refs/tags/*", Permission.PUSH);
     PushOneCommit.AnnotatedTag tag =
         new PushOneCommit.AnnotatedTag("v1.0", "annotation", admin.getIdent());
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
@@ -95,7 +95,7 @@
 
   @Test
   public void submitOnPushToRefsMetaConfig() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/meta/config");
+    grant(project, "refs/for/refs/meta/config", Permission.SUBMIT);
 
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
     testRepo.reset(RefNames.REFS_CONFIG);
@@ -113,7 +113,7 @@
     push("refs/heads/master", "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "a.txt", "other content");
     r.assertErrorStatus();
@@ -129,7 +129,7 @@
     push(master, "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "b.txt", "other content");
     r.assertOkStatus();
@@ -142,7 +142,7 @@
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
 
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
     r =
         push(
             "refs/for/master%submit",
@@ -158,7 +158,7 @@
   @Test
   public void submitOnPushNotAllowed_Error() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master%submit");
-    r.assertErrorStatus("submit not allowed");
+    r.assertErrorStatus("update by submit not permitted");
   }
 
   @Test
@@ -170,13 +170,7 @@
         push(
             "refs/for/master%submit",
             PushOneCommit.SUBJECT, "a.txt", "other content", r.getChangeId());
-    r.assertErrorStatus("submit not allowed");
-  }
-
-  @Test
-  public void submitOnPushingDraft_Error() throws Exception {
-    PushOneCommit.Result r = pushTo("refs/for/master%draft,submit");
-    r.assertErrorStatus("cannot submit draft");
+    r.assertErrorStatus("update by submit not permitted");
   }
 
   @Test
@@ -188,7 +182,7 @@
 
   @Test
   public void mergeOnPushToBranch() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
     r.assertOkStatus();
@@ -213,9 +207,9 @@
     enableCreateNewChangeForAllNotInTarget();
     String master = "refs/heads/master";
     String other = "refs/heads/other";
-    grant(Permission.PUSH, project, master);
-    grant(Permission.CREATE, project, other);
-    grant(Permission.PUSH, project, other);
+    grant(project, master, Permission.PUSH);
+    grant(project, other, Permission.CREATE);
+    grant(project, other, Permission.PUSH);
     RevCommit masterRev = getRemoteHead();
     pushCommitTo(masterRev, other);
     PushOneCommit.Result r = createChange();
@@ -250,7 +244,7 @@
 
   @Test
   public void mergeOnPushToBranchWithNewPatchset() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -285,7 +279,7 @@
 
   @Test
   public void mergeOnPushToBranchWithOldPatchset() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -312,7 +306,7 @@
 
   @Test
   public void mergeMultipleOnPushToBranchWithNewPatchset() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
 
     // Create 2 changes.
     ObjectId initialHead = getRemoteHead();
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 f72e978..689c5b7 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.git;
 
 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.GerritConfig;
@@ -503,4 +504,42 @@
 
     expectToHaveSubmoduleState(superRepo, "master", "nested/subscribed-to-project", subHEAD);
   }
+
+  @Test
+  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
+  @GerritConfig(name = "submodule.maxCommitMessages", value = "1")
+  public void submoduleSubjectCommitMessageCountLimit() throws Exception {
+    testSubmoduleSubjectCommitMessageAndExpectTruncation();
+  }
+
+  @Test
+  @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY")
+  @GerritConfig(name = "submodule.maxCombinedCommitMessageSize", value = "220")
+  public void submoduleSubjectCommitMessageSizeLimit() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isFalse();
+    testSubmoduleSubjectCommitMessageAndExpectTruncation();
+  }
+
+  private void testSubmoduleSubjectCommitMessageAndExpectTruncation() 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");
+    // The first update doesn't include the rev log, so we ignore it
+    pushChangeTo(subRepo, "master");
+
+    // Next, we push two commits at once. Since maxCommitMessages=1, we expect to see only the first
+    // message plus ellipsis to mark truncation.
+    ObjectId subHEAD = pushChangesTo(subRepo, "master", 2);
+    RevCommit subCommitMsg = subRepo.getRevWalk().parseCommit(subHEAD);
+    expectToHaveCommitMessage(
+        superRepo,
+        "master",
+        String.format(
+            "Update git submodules\n\n* Update %s from branch 'master'\n  to %s\n  - %s\n\n[...]",
+            name("subscribed-to-project"), subHEAD.getName(), subCommitMsg.getShortMessage()));
+  }
 }
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 2ec3810..b1a8e0f 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
@@ -16,20 +16,25 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 
+import com.google.common.collect.ImmutableList;
 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.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
 import com.google.gerrit.testutil.ConfigSuite;
+import java.util.ArrayDeque;
 import java.util.Map;
 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.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
@@ -786,4 +791,69 @@
     expectToHaveSubmoduleState(repoA, "master", "project-b", repoB, "master");
     expectToHaveSubmoduleState(repoA, "dev", "project-b", repoB, "dev");
   }
+
+  @Test
+  public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+
+    TestRepository<?> superRepo = createProjectWithPush("super-project");
+    TestRepository<?> sub1 = createProjectWithPush("sub1");
+    TestRepository<?> sub2 = createProjectWithPush("sub2");
+
+    allowMatchingSubmoduleSubscription(
+        "sub1", "refs/heads/master", "super-project", "refs/heads/master");
+    allowMatchingSubmoduleSubscription(
+        "sub2", "refs/heads/master", "super-project", "refs/heads/master");
+
+    Config config = new Config();
+    prepareSubmoduleConfigEntry(config, "sub1", "master");
+    prepareSubmoduleConfigEntry(config, "sub2", "master");
+    pushSubmoduleConfig(superRepo, "master", config);
+
+    ObjectId superPreviousId = pushChangeTo(superRepo, "master");
+
+    String topic = "same-topic";
+    ObjectId sub1Id = pushChangeTo(sub1, "refs/for/master", "some message", topic);
+    ObjectId sub2Id = pushChangeTo(sub2, "refs/for/master", "some message", topic);
+
+    String changeId1 = getChangeId(sub1, sub1Id).get();
+    String changeId2 = getChangeId(sub2, sub2Id).get();
+    approve(changeId1);
+    approve(changeId2);
+
+    TestSubmitInput input = new TestSubmitInput();
+    input.generateLockFailures =
+        new ArrayDeque<>(
+            ImmutableList.of(
+                false, // Change 1, attempt 1: success
+                true, // Change 2, attempt 1: lock failure
+                false, // Change 1, attempt 2: success
+                false, // Change 2, attempt 2: success
+                false)); // Leftover value to check total number of calls.
+    gApi.changes().id(changeId1).current().submit(input);
+
+    assertThat(info(changeId1).status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(info(changeId2).status).isEqualTo(ChangeStatus.MERGED);
+
+    sub1.git().fetch().call();
+    RevWalk rw1 = sub1.getRevWalk();
+    RevCommit master1 = rw1.parseCommit(getRemoteHead(name("sub1"), "master"));
+    RevCommit change1Ps = parseCurrentRevision(rw1, changeId1);
+    assertThat(rw1.isMergedInto(change1Ps, master1)).isTrue();
+
+    sub2.git().fetch().call();
+    RevWalk rw2 = sub2.getRevWalk();
+    RevCommit master2 = rw2.parseCommit(getRemoteHead(name("sub2"), "master"));
+    RevCommit change2Ps = parseCurrentRevision(rw2, changeId2);
+    assertThat(rw2.isMergedInto(change2Ps, master2)).isTrue();
+
+    assertThat(input.generateLockFailures).containsExactly(false);
+
+    expectToHaveSubmoduleState(superRepo, "master", "sub1", sub1, "master");
+    expectToHaveSubmoduleState(superRepo, "master", "sub2", sub2, "master");
+
+    assertWithMessage("submodule subscription update should have made one commit")
+        .that(superRepo.getRepository().resolve("origin/master^"))
+        .isEqualTo(superPreviousId);
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index cca66b3..e5496c0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -14,18 +14,31 @@
 
 package com.google.gerrit.acceptance.pgm;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.extensions.client.ListGroupsOption.MEMBERS;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.io.MoreFiles;
 import com.google.common.io.RecursiveDeleteOption;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.acceptance.pgm.IndexUpgradeController.UpgradeAttempt;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Injector;
+import com.google.inject.Provider;
 import java.nio.file.Files;
+import java.util.Set;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
 import org.junit.Test;
 
 @NoHttpd
@@ -33,28 +46,20 @@
   /** @param injector injector */
   public abstract void configureIndex(Injector injector) throws Exception;
 
+  private static final String CHANGES = ChangeSchemaDefinitions.NAME;
+
+  private String changeId;
+
   @Test
   public void reindexFromScratch() throws Exception {
-    Project.NameKey project = new Project.NameKey("project");
-    String changeId;
-    try (ServerContext ctx = startServer()) {
-      configureIndex(ctx.getInjector());
-      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
-      gApi.projects().create("project");
-
-      ChangeInput in = new ChangeInput();
-      in.project = project.get();
-      in.branch = "master";
-      in.subject = "Test change";
-      in.newBranch = true;
-      changeId = gApi.changes().create(in).info().changeId;
-    }
+    setUpChange();
 
     MoreFiles.deleteRecursively(sitePaths.index_dir, RecursiveDeleteOption.ALLOW_INSECURE);
     Files.createDirectory(sitePaths.index_dir);
     assertServerStartupFails();
 
     runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+    assertReady(ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion());
 
     try (ServerContext ctx = startServer()) {
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
@@ -72,4 +77,103 @@
           .containsExactly(adminId.get());
     }
   }
+
+  @Test
+  public void onlineUpgradeChanges() throws Exception {
+    int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
+    int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
+
+    // Before storing any changes, switch back to the previous version.
+    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
+    status.setReady(CHANGES, currVersion, false);
+    status.setReady(CHANGES, prevVersion, true);
+    status.save();
+    assertReady(prevVersion);
+
+    setOnlineUpgradeConfig(false);
+    setUpChange();
+    setOnlineUpgradeConfig(true);
+
+    IndexUpgradeController u = new IndexUpgradeController(1);
+    try (ServerContext ctx = startServer(u.module())) {
+      assertSearchVersion(ctx, prevVersion);
+      assertWriteVersions(ctx, prevVersion, currVersion);
+
+      // Updating and searching old schema version works.
+      Provider<InternalChangeQuery> queryProvider =
+          ctx.getInjector().getProvider(InternalChangeQuery.class);
+      assertThat(queryProvider.get().byKey(new Change.Key(changeId))).hasSize(1);
+      assertThat(queryProvider.get().byTopicOpen("topic1")).isEmpty();
+
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      gApi.changes().id(changeId).topic("topic1");
+      assertThat(queryProvider.get().byTopicOpen("topic1")).hasSize(1);
+
+      u.runUpgrades();
+      assertThat(u.getStartedAttempts())
+          .containsExactly(UpgradeAttempt.create(CHANGES, prevVersion, currVersion));
+      assertThat(u.getSucceededAttempts())
+          .containsExactly(UpgradeAttempt.create(CHANGES, prevVersion, currVersion));
+      assertThat(u.getFailedAttempts()).isEmpty();
+
+      assertReady(currVersion);
+      assertSearchVersion(ctx, currVersion);
+      assertWriteVersions(ctx, currVersion);
+
+      // Updating and searching new schema version works.
+      assertThat(queryProvider.get().byTopicOpen("topic1")).hasSize(1);
+      assertThat(queryProvider.get().byTopicOpen("topic2")).isEmpty();
+      gApi.changes().id(changeId).topic("topic2");
+      assertThat(queryProvider.get().byTopicOpen("topic1")).isEmpty();
+      assertThat(queryProvider.get().byTopicOpen("topic2")).hasSize(1);
+    }
+  }
+
+  private void setUpChange() throws Exception {
+    Project.NameKey project = new Project.NameKey("project");
+    try (ServerContext ctx = startServer()) {
+      configureIndex(ctx.getInjector());
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      gApi.projects().create(project.get());
+
+      ChangeInput in = new ChangeInput(project.get(), "master", "Test change");
+      in.newBranch = true;
+      changeId = gApi.changes().create(in).info().changeId;
+    }
+  }
+
+  private void setOnlineUpgradeConfig(boolean enable) throws Exception {
+    FileBasedConfig cfg = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
+    cfg.load();
+    cfg.setBoolean("index", null, "onlineUpgrade", enable);
+    cfg.save();
+  }
+
+  private void assertSearchVersion(ServerContext ctx, int expected) {
+    assertThat(
+            ctx.getInjector()
+                .getInstance(ChangeIndexCollection.class)
+                .getSearchIndex()
+                .getSchema()
+                .getVersion())
+        .named("search version")
+        .isEqualTo(expected);
+  }
+
+  private void assertWriteVersions(ServerContext ctx, Integer... expected) {
+    assertThat(
+            ctx.getInjector().getInstance(ChangeIndexCollection.class).getWriteIndexes().stream()
+                .map(i -> i.getSchema().getVersion()))
+        .named("write versions")
+        .containsExactlyElementsIn(ImmutableSet.copyOf(expected));
+  }
+
+  private void assertReady(int expectedReady) throws Exception {
+    Set<Integer> allVersions = ChangeSchemaDefinitions.INSTANCE.getSchemas().keySet();
+    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
+    assertThat(
+            allVersions.stream().collect(toImmutableMap(v -> v, v -> status.getReady(CHANGES, v))))
+        .named("ready state for index versions")
+        .isEqualTo(allVersions.stream().collect(toImmutableMap(v -> v, v -> v == expectedReady)));
+  }
 }
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
index c094b5b..42a6f9a 100644
--- 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
@@ -8,6 +8,7 @@
     ),
     group = "pgm",
     labels = ["pgm"],
+    vm_args = ["-Xmx512m"],
     deps = [":util"],
 )
 
@@ -22,12 +23,19 @@
         "exclusive",
         "pgm",
     ],
-    deps = [":util"],
+    deps = [
+        ":util",
+        "//gerrit-elasticsearch:elasticsearch",
+        "//gerrit-elasticsearch:elasticsearch_test_utils",
+    ],
 )
 
 java_library(
     name = "util",
     testonly = 1,
-    srcs = ["AbstractReindexTests.java"],
+    srcs = [
+        "AbstractReindexTests.java",
+        "IndexUpgradeController.java",
+    ],
     deps = ["//gerrit-acceptance-tests:lib"],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index 4e1ec08..0da3198 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -14,48 +14,35 @@
 
 package com.google.gerrit.acceptance.pgm;
 
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
+
 import com.google.gerrit.elasticsearch.ElasticVersion;
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.inject.Injector;
-import java.util.UUID;
 import org.eclipse.jgit.lib.Config;
-import org.junit.After;
 import org.junit.Before;
 
 public class ElasticReindexIT extends AbstractReindexTests {
-  private static ElasticContainer<?> container;
-
-  private static Config getConfig(ElasticVersion version) {
-    ElasticNodeInfo elasticNodeInfo;
-    container = ElasticContainer.createAndStart(version);
-    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-    String indicesPrefix = UUID.randomUUID().toString();
-    Config cfg = new Config();
-    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
-    return cfg;
-  }
 
   @ConfigSuite.Default
-  public static Config elasticsearchV2() {
-    return getConfig(ElasticVersion.V2_4);
-  }
-
-  @ConfigSuite.Config
   public static Config elasticsearchV5() {
     return getConfig(ElasticVersion.V5_6);
   }
 
   @ConfigSuite.Config
   public static Config elasticsearchV6() {
-    return getConfig(ElasticVersion.V6_4);
+    return getConfig(ElasticVersion.V6_8);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV7() {
+    return getConfig(ElasticVersion.V7_3);
   }
 
   @Override
   public void configureIndex(Injector injector) throws Exception {
-    ElasticTestUtils.createAllIndexes(injector);
+    createAllIndexes(injector);
   }
 
   @Before
@@ -63,12 +50,4 @@
     assertServerStartupFails();
     runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
   }
-
-  @After
-  public void stopElasticServer() {
-    if (container != null) {
-      container.stop();
-      container = null;
-    }
-  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
new file mode 100644
index 0000000..9cdcb40
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
@@ -0,0 +1,121 @@
+// 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.acceptance.pgm;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.index.OnlineUpgradeListener;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+class IndexUpgradeController implements OnlineUpgradeListener {
+  @AutoValue
+  abstract static class UpgradeAttempt {
+    static UpgradeAttempt create(String name, int oldVersion, int newVersion) {
+      return new AutoValue_IndexUpgradeController_UpgradeAttempt(name, oldVersion, newVersion);
+    }
+
+    abstract String name();
+
+    abstract int oldVersion();
+
+    abstract int newVersion();
+  }
+
+  private final int numExpected;
+  private final CountDownLatch readyToStart;
+  private final CountDownLatch started;
+  private final CountDownLatch finished;
+
+  private final List<UpgradeAttempt> startedAttempts;
+  private final List<UpgradeAttempt> succeededAttempts;
+  private final List<UpgradeAttempt> failedAttempts;
+
+  IndexUpgradeController(int numExpected) {
+    this.numExpected = numExpected;
+    readyToStart = new CountDownLatch(1);
+    started = new CountDownLatch(numExpected);
+    finished = new CountDownLatch(numExpected);
+    startedAttempts = new ArrayList<>();
+    succeededAttempts = new ArrayList<>();
+    failedAttempts = new ArrayList<>();
+  }
+
+  Module module() {
+    return new AbstractModule() {
+      @Override
+      public void configure() {
+        DynamicSet.bind(binder(), OnlineUpgradeListener.class)
+            .toInstance(IndexUpgradeController.this);
+      }
+    };
+  }
+
+  @Override
+  public synchronized void onStart(String name, int oldVersion, int newVersion) {
+    UpgradeAttempt a = UpgradeAttempt.create(name, oldVersion, newVersion);
+    try {
+      readyToStart.await();
+    } catch (InterruptedException e) {
+      throw new AssertionError("interrupted waiting to start " + a, e);
+    }
+    checkState(
+        started.getCount() > 0, "already started %s upgrades, can't start %s", numExpected, a);
+    startedAttempts.add(a);
+    started.countDown();
+  }
+
+  @Override
+  public synchronized void onSuccess(String name, int oldVersion, int newVersion) {
+    finish(UpgradeAttempt.create(name, oldVersion, newVersion), succeededAttempts);
+  }
+
+  @Override
+  public synchronized void onFailure(String name, int oldVersion, int newVersion) {
+    finish(UpgradeAttempt.create(name, oldVersion, newVersion), failedAttempts);
+  }
+
+  private synchronized void finish(UpgradeAttempt a, List<UpgradeAttempt> out) {
+    checkState(readyToStart.getCount() == 0, "shouldn't be finishing upgrade before starting");
+    checkState(
+        finished.getCount() > 0, "already finished %s upgrades, can't finish %s", numExpected, a);
+    out.add(a);
+    finished.countDown();
+  }
+
+  void runUpgrades() throws Exception {
+    readyToStart.countDown();
+    started.await();
+    finished.await();
+  }
+
+  synchronized ImmutableList<UpgradeAttempt> getStartedAttempts() {
+    return ImmutableList.copyOf(startedAttempts);
+  }
+
+  synchronized ImmutableList<UpgradeAttempt> getSucceededAttempts() {
+    return ImmutableList.copyOf(succeededAttempts);
+  }
+
+  synchronized ImmutableList<UpgradeAttempt> getFailedAttempts() {
+    return ImmutableList.copyOf(failedAttempts);
+  }
+}
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 b4e06d0..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/RebuildNoteDbIT.java
+++ /dev/null
@@ -1,68 +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.FileWriteMode;
-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 java.io.File;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-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.asCharSink(
-            new File(sitePath.toString(), "etc/gerrit.config"), UTF_8, FileWriteMode.APPEND)
-        .write(ConfigNotesMigration.allEnabledConfig().toText());
-    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/StandaloneNoteDbMigrationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java
new file mode 100644
index 0000000..342268b
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/pgm/StandaloneNoteDbMigrationIT.java
@@ -0,0 +1,352 @@
+// 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 com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.common.ChangeInput;
+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.LocalDiskRepositoryManager;
+import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
+import com.google.gerrit.server.notedb.NotesMigrationState;
+import com.google.gerrit.server.schema.ReviewDbFactory;
+import com.google.gerrit.testutil.NoteDbMode;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Key;
+import com.google.inject.TypeLiteral;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for NoteDb migrations where the entry point is through a program, {@code
+ * migrate-to-note-db} or {@code daemon}.
+ *
+ * <p><strong>Note:</strong> These tests are very slow due to the repeated daemon startup. Prefer
+ * adding tests to {@link com.google.gerrit.acceptance.server.notedb.OnlineNoteDbMigrationIT} if
+ * possible.
+ */
+@NoHttpd
+public class StandaloneNoteDbMigrationIT extends StandaloneSiteTest {
+  private StoredConfig gerritConfig;
+  private StoredConfig noteDbConfig;
+
+  private Project.NameKey project;
+  private Change.Id changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
+    gerritConfig = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
+    // Unlike in the running server, for tests, we don't stack notedb.config on gerrit.config.
+    noteDbConfig = new FileBasedConfig(sitePaths.notedb_config.toFile(), FS.detect());
+  }
+
+  @Test
+  public void rebuildOneChangeTrialMode() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoAutoMigrateConfig(noteDbConfig);
+    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
+    setUpOneChange();
+
+    migrate("--trial");
+    assertNotesMigrationState(NotesMigrationState.READ_WRITE_NO_SEQUENCE);
+
+    try (ServerContext ctx = startServer()) {
+      GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
+      ObjectId metaId;
+      try (Repository repo = repoManager.openRepository(project)) {
+        Ref ref = repo.exactRef(RefNames.changeMetaRef(changeId));
+        assertThat(ref).isNotNull();
+        metaId = ref.getObjectId();
+      }
+
+      try (ReviewDb db = openUnderlyingReviewDb(ctx)) {
+        Change c = db.changes().get(changeId);
+        assertThat(c).isNotNull();
+        NoteDbChangeState state = NoteDbChangeState.parse(c);
+        assertThat(state).isNotNull();
+        assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+        assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
+      }
+    }
+  }
+
+  @Test
+  public void migrateOneChange() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoAutoMigrateConfig(noteDbConfig);
+    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
+    setUpOneChange();
+
+    migrate();
+    assertNotesMigrationState(NotesMigrationState.NOTE_DB);
+
+    File allUsersDir;
+    try (ServerContext ctx = startServer()) {
+      GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
+      try (Repository repo = repoManager.openRepository(project)) {
+        assertThat(repo.exactRef(RefNames.changeMetaRef(changeId))).isNotNull();
+      }
+      assertThat(repoManager).isInstanceOf(LocalDiskRepositoryManager.class);
+      try (Repository repo =
+          repoManager.openRepository(ctx.getInjector().getInstance(AllUsersName.class))) {
+        allUsersDir = repo.getDirectory();
+      }
+
+      try (ReviewDb db = openUnderlyingReviewDb(ctx)) {
+        Change c = db.changes().get(changeId);
+        assertThat(c).isNotNull();
+        NoteDbChangeState state = NoteDbChangeState.parse(c);
+        assertThat(state).isNotNull();
+        assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.NOTE_DB);
+        assertThat(state.getRefState()).isEmpty();
+
+        ChangeInput in = new ChangeInput(project.get(), "master", "NoteDb-only change");
+        in.newBranch = true;
+        GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+        Change.Id id2 = new Change.Id(gApi.changes().create(in).info()._number);
+        assertThat(db.changes().get(id2)).isNull();
+      }
+    }
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertAutoMigrateConfig(noteDbConfig, false);
+
+    try (FileRepository repo = new FileRepository(allUsersDir)) {
+      try (Stream<Path> paths = Files.walk(repo.getObjectsDirectory().toPath())) {
+        assertThat(paths.filter(p -> !p.toString().contains("pack") && Files.isRegularFile(p)))
+            .named("loose object files in All-Users")
+            .isEmpty();
+      }
+      assertThat(repo.getObjectDatabase().getPacks()).named("packfiles in All-Users").hasSize(1);
+    }
+  }
+
+  @Test
+  public void migrationWithReindex() throws Exception {
+    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
+    setUpOneChange();
+
+    int version = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
+    assertThat(status.getReady(ChangeSchemaDefinitions.NAME, version)).isTrue();
+    status.setReady(ChangeSchemaDefinitions.NAME, version, false);
+    status.save();
+    assertServerStartupFails();
+
+    migrate();
+    assertNotesMigrationState(NotesMigrationState.NOTE_DB);
+
+    status = new GerritIndexStatus(sitePaths);
+    assertThat(status.getReady(ChangeSchemaDefinitions.NAME, version)).isTrue();
+  }
+
+  @Test
+  public void onlineMigrationViaDaemon() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoAutoMigrateConfig(noteDbConfig);
+
+    testOnlineMigration(u -> startServer(u.module(), "--migrate-to-note-db", "true"));
+
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertAutoMigrateConfig(noteDbConfig, false);
+  }
+
+  @Test
+  public void onlineMigrationViaConfig() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoAutoMigrateConfig(noteDbConfig);
+
+    testOnlineMigration(
+        u -> {
+          gerritConfig.setBoolean("noteDb", "changes", "autoMigrate", true);
+          gerritConfig.save();
+          return startServer(u.module());
+        });
+
+    // Auto-migration is turned off in notedb.config, which takes precedence, but is still on in
+    // gerrit.config. This means Puppet can continue overwriting gerrit.config without turning
+    // auto-migration back on.
+    assertAutoMigrateConfig(gerritConfig, true);
+    assertAutoMigrateConfig(noteDbConfig, false);
+  }
+
+  @Test
+  public void onlineMigrationTrialModeViaFlag() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoTrialConfig(gerritConfig);
+
+    assertNoAutoMigrateConfig(noteDbConfig);
+    assertNoTrialConfig(noteDbConfig);
+
+    testOnlineMigration(
+        u -> startServer(u.module(), "--migrate-to-note-db", "--trial"),
+        NotesMigrationState.READ_WRITE_NO_SEQUENCE);
+
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoTrialConfig(gerritConfig);
+
+    assertAutoMigrateConfig(noteDbConfig, true);
+    assertTrialConfig(noteDbConfig, true);
+  }
+
+  @Test
+  public void onlineMigrationTrialModeViaConfig() throws Exception {
+    assertNoAutoMigrateConfig(gerritConfig);
+    assertNoTrialConfig(gerritConfig);
+
+    assertNoAutoMigrateConfig(noteDbConfig);
+    assertNoTrialConfig(noteDbConfig);
+
+    testOnlineMigration(
+        u -> {
+          gerritConfig.setBoolean("noteDb", "changes", "autoMigrate", true);
+          gerritConfig.setBoolean("noteDb", "changes", "trial", true);
+          gerritConfig.save();
+          return startServer(u.module());
+        },
+        NotesMigrationState.READ_WRITE_NO_SEQUENCE);
+
+    assertAutoMigrateConfig(gerritConfig, true);
+    assertTrialConfig(gerritConfig, true);
+
+    assertAutoMigrateConfig(noteDbConfig, true);
+    assertTrialConfig(noteDbConfig, true);
+  }
+
+  @FunctionalInterface
+  private interface StartServerWithMigration {
+    ServerContext start(IndexUpgradeController u) throws Exception;
+  }
+
+  private void testOnlineMigration(StartServerWithMigration start) throws Exception {
+    testOnlineMigration(start, NotesMigrationState.NOTE_DB);
+  }
+
+  private void testOnlineMigration(
+      StartServerWithMigration start, NotesMigrationState expectedEndState) throws Exception {
+    assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
+    int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
+    int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
+
+    // Before storing any changes, switch back to the previous version.
+    GerritIndexStatus status = new GerritIndexStatus(sitePaths);
+    status.setReady(ChangeSchemaDefinitions.NAME, currVersion, false);
+    status.setReady(ChangeSchemaDefinitions.NAME, prevVersion, true);
+    status.save();
+
+    setOnlineUpgradeConfig(false);
+    setUpOneChange();
+    setOnlineUpgradeConfig(true);
+
+    IndexUpgradeController u = new IndexUpgradeController(1);
+    try (ServerContext ctx = start.start(u)) {
+      ChangeIndexCollection indexes = ctx.getInjector().getInstance(ChangeIndexCollection.class);
+      assertThat(indexes.getSearchIndex().getSchema().getVersion()).isEqualTo(prevVersion);
+
+      // Index schema upgrades happen after NoteDb migration, so waiting for those to complete
+      // should be sufficient.
+      u.runUpgrades();
+
+      assertThat(indexes.getSearchIndex().getSchema().getVersion()).isEqualTo(currVersion);
+      assertNotesMigrationState(expectedEndState);
+    }
+  }
+
+  private void setUpOneChange() throws Exception {
+    project = new Project.NameKey("project");
+    try (ServerContext ctx = startServer()) {
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      gApi.projects().create("project");
+
+      ChangeInput in = new ChangeInput(project.get(), "master", "Test change");
+      in.newBranch = true;
+      changeId = new Change.Id(gApi.changes().create(in).info()._number);
+    }
+  }
+
+  private void migrate(String... additionalArgs) throws Exception {
+    runGerrit(
+        ImmutableList.of(
+            "migrate-to-note-db", "-d", sitePaths.site_path.toString(), "--show-stack-trace"),
+        ImmutableList.copyOf(additionalArgs));
+  }
+
+  private void assertNotesMigrationState(NotesMigrationState expected) throws Exception {
+    noteDbConfig.load();
+    assertThat(NotesMigrationState.forConfig(noteDbConfig)).hasValue(expected);
+  }
+
+  private ReviewDb openUnderlyingReviewDb(ServerContext ctx) throws Exception {
+    return ctx.getInjector()
+        .getInstance(Key.get(new TypeLiteral<SchemaFactory<ReviewDb>>() {}, ReviewDbFactory.class))
+        .open();
+  }
+
+  private static void assertNoAutoMigrateConfig(StoredConfig cfg) throws Exception {
+    cfg.load();
+    assertThat(cfg.getString("noteDb", "changes", "autoMigrate")).isNull();
+  }
+
+  private static void assertAutoMigrateConfig(StoredConfig cfg, boolean expected) throws Exception {
+    cfg.load();
+    assertThat(cfg.getString("noteDb", "changes", "autoMigrate")).isNotNull();
+    assertThat(cfg.getBoolean("noteDb", "changes", "autoMigrate", false)).isEqualTo(expected);
+  }
+
+  private static void assertNoTrialConfig(StoredConfig cfg) throws Exception {
+    cfg.load();
+    assertThat(cfg.getString("noteDb", "changes", "trial")).isNull();
+  }
+
+  private static void assertTrialConfig(StoredConfig cfg, boolean expected) throws Exception {
+    cfg.load();
+    assertThat(cfg.getString("noteDb", "changes", "trial")).isNotNull();
+    assertThat(cfg.getBoolean("noteDb", "changes", "trial", false)).isEqualTo(expected);
+  }
+
+  private void setOnlineUpgradeConfig(boolean enable) throws Exception {
+    gerritConfig.load();
+    gerritConfig.setBoolean("index", null, "onlineUpgrade", enable);
+    gerritConfig.save();
+  }
+}
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 ec0197a..e7ce43f 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
@@ -15,7 +15,7 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.common.data.GlobalCapability.ACCESS_DATABASE;
 import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
@@ -61,8 +61,7 @@
           assertThat(info.queryLimit.min).isEqualTo((short) 0);
           assertThat(info.queryLimit.max).isEqualTo((short) DEFAULT_MAX_QUERY_LIMIT);
         } else {
-          assert_()
-              .withFailureMessage(String.format("capability %s was not granted", c))
+          assertWithMessage(String.format("capability %s was not granted", c))
               .that((Boolean) CapabilityInfo.class.getField(c).get(info))
               .isTrue();
         }
@@ -86,7 +85,7 @@
       } else if (PRIORITY.equals(c)) {
         assertThat(info.priority).isFalse();
       } else if (QUERY_LIMIT.equals(c)) {
-        assert_().withFailureMessage("missing queryLimit").that(info.queryLimit).isNotNull();
+        assertWithMessage("missing queryLimit").that(info.queryLimit).isNotNull();
         assertThat(info.queryLimit.min).isEqualTo((short) 0);
         assertThat(info.queryLimit.max).isEqualTo((short) DEFAULT_MAX_QUERY_LIMIT);
       } else if (ACCESS_DATABASE.equals(c)) {
@@ -94,8 +93,7 @@
       } else if (RUN_AS.equals(c)) {
         assertThat(info.runAs).isFalse();
       } else {
-        assert_()
-            .withFailureMessage(String.format("capability %s was not granted", c))
+        assertWithMessage(String.format("capability %s was not granted", c))
             .that((Boolean) CapabilityInfo.class.getField(c).get(info))
             .isTrue();
       }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index c96780a..33313d1 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -15,36 +15,90 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.junit.Assert.fail;
 
+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 com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountExternalIdsInput;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate.RefsMetaExternalIdsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.LockFailureException;
 import com.google.gson.reflect.TypeToken;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+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.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.eclipse.jgit.util.MutableInteger;
 import org.junit.Test;
 
 @Sandboxed
 public class ExternalIdIT extends AbstractDaemonTest {
+  @Inject private AllUsersName allUsers;
+  @Inject private ExternalIdsUpdate.Server extIdsUpdate;
+  @Inject private ExternalIds externalIds;
+  @Inject private ExternalIdReader externalIdReader;
+  @Inject private MetricMaker metricMaker;
 
   @Test
-  public void getExternalIDs() throws Exception {
+  public void getExternalIds() throws Exception {
     Collection<ExternalId> expectedIds = accountCache.get(user.getId()).getExternalIds();
-
-    List<AccountExternalIdInfo> expectedIdInfos = new ArrayList<>();
-    for (ExternalId id : expectedIds) {
-      AccountExternalIdInfo info = new AccountExternalIdInfo();
-      info.identity = id.key().get();
-      info.emailAddress = id.email();
-      info.canDelete = !id.isScheme(SCHEME_USERNAME) ? true : null;
-      info.trusted = true;
-      expectedIdInfos.add(info);
-    }
+    List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
 
     RestResponse response = userRestSession.get("/accounts/self/external.ids");
     response.assertOK();
@@ -60,7 +114,35 @@
   }
 
   @Test
-  public void deleteExternalIDs() throws Exception {
+  public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("access database not permitted");
+    gApi.accounts().id(admin.id.get()).getExternalIds();
+  }
+
+  @Test
+  public void getExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    Collection<ExternalId> expectedIds = accountCache.get(admin.getId()).getExternalIds();
+    List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
+
+    RestResponse response = userRestSession.get("/accounts/" + admin.id + "/external.ids");
+    response.assertOK();
+
+    List<AccountExternalIdInfo> results =
+        newGson()
+            .fromJson(
+                response.getReader(), new TypeToken<List<AccountExternalIdInfo>>() {}.getType());
+
+    Collections.sort(expectedIdInfos);
+    Collections.sort(results);
+    assertThat(results).containsExactlyElementsIn(expectedIdInfos);
+  }
+
+  @Test
+  public void deleteExternalIds() throws Exception {
     setApiUser(user);
     List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
 
@@ -86,7 +168,70 @@
   }
 
   @Test
-  public void deleteExternalIDs_Conflict() throws Exception {
+  public void deleteExternalIdsOfOtherUserNotAllowed() throws Exception {
+    List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("access database not permitted");
+    gApi.accounts()
+        .id(admin.id.get())
+        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+  }
+
+  @Test
+  public void deleteExternalIdOfOtherUserUnderOwnAccount_UnprocessableEntity() throws Exception {
+    List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
+    setApiUser(user);
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage(String.format("External id %s does not exist", extIds.get(0).identity));
+    gApi.accounts()
+        .self()
+        .deleteExternalIds(extIds.stream().map(e -> e.identity).collect(toList()));
+  }
+
+  @Test
+  public void deleteExternalIdsOfOtherUserWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
+
+    List<String> toDelete = new ArrayList<>();
+    List<AccountExternalIdInfo> expectedIds = new ArrayList<>();
+    for (AccountExternalIdInfo id : externalIds) {
+      if (id.canDelete != null && id.canDelete) {
+        toDelete.add(id.identity);
+        continue;
+      }
+      expectedIds.add(id);
+    }
+
+    assertThat(toDelete).hasSize(1);
+
+    setApiUser(user);
+    RestResponse response =
+        userRestSession.post("/accounts/" + admin.id + "/external.ids:delete", toDelete);
+    response.assertNoContent();
+    List<AccountExternalIdInfo> results = gApi.accounts().id(admin.id.get()).getExternalIds();
+    // The external ID in WebSession will not be set for tests, resulting that
+    // "mailto:user@example.com" can be deleted while "username:user" can't.
+    assertThat(results).hasSize(1);
+    assertThat(results).containsExactlyElementsIn(expectedIds);
+  }
+
+  @Test
+  public void deleteExternalIdOfPreferredEmail() throws Exception {
+    String preferredEmail = gApi.accounts().self().get().email;
+    assertThat(preferredEmail).isNotNull();
+
+    gApi.accounts()
+        .self()
+        .deleteExternalIds(
+            ImmutableList.of(ExternalId.Key.create(SCHEME_MAILTO, preferredEmail).get()));
+    assertThat(gApi.accounts().self().get().email).isNull();
+  }
+
+  @Test
+  public void deleteExternalIds_Conflict() throws Exception {
     List<String> toDelete = new ArrayList<>();
     String externalIdStr = "username:" + user.username;
     toDelete.add(externalIdStr);
@@ -97,7 +242,7 @@
   }
 
   @Test
-  public void deleteExternalIDs_UnprocessableEntity() throws Exception {
+  public void deleteExternalIds_UnprocessableEntity() throws Exception {
     List<String> toDelete = new ArrayList<>();
     String externalIdStr = "mailto:user@domain.com";
     toDelete.add(externalIdStr);
@@ -106,4 +251,723 @@
     assertThat(response.getEntityContent())
         .isEqualTo(String.format("External id %s does not exist", externalIdStr));
   }
+
+  @Test
+  public void fetchExternalIdsBranch() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
+
+    // refs/meta/external-ids is only visible to users with the 'Access Database' capability
+    try {
+      fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+      fail("expected TransportException");
+    } catch (TransportException e) {
+      assertThat(e.getMessage())
+          .isEqualTo(
+              "Remote does not have " + RefNames.REFS_EXTERNAL_IDS + " available for fetch.");
+    }
+
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    // re-clone to get new request context, otherwise the old global capabilities are still cached
+    // in the IdentifiedUser object
+    allUsersRepo = cloneProject(allUsers, user);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+  }
+
+  @Test
+  public void pushToExternalIdsBranch() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    // different case email is allowed
+    ExternalId newExtId = createExternalIdWithOtherCaseEmail("foo:bar");
+    addExtId(allUsersRepo, newExtId);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    List<AccountExternalIdInfo> extIdsBefore = gApi.accounts().self().getExternalIds();
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertThat(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS).getStatus()).isEqualTo(Status.OK);
+
+    List<AccountExternalIdInfo> extIdsAfter = gApi.accounts().self().getExternalIds();
+    assertThat(extIdsAfter)
+        .containsExactlyElementsIn(
+            Iterables.concat(extIdsBefore, ImmutableSet.of(toExternalIdInfo(newExtId))));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithoutAccountId() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithoutAccountId(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithKeyThatDoesntMatchTheNoteId()
+      throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithKeyThatDoesntMatchNoteId(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithInvalidConfig() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithInvalidConfig(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithEmptyNote() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithEmptyNote(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdForNonExistingAccount() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(
+        createExternalIdForNonExistingAccount("foo:bar"));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithInvalidEmail() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(
+        createExternalIdWithInvalidEmail("foo:bar"));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsDuplicateEmails() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(
+        createExternalIdWithDuplicateEmail("foo:bar"));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsBadPassword() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(createExternalIdWithBadPassword("foo"));
+  }
+
+  private void testPushToExternalIdsBranchRejectsInvalidExternalId(ExternalId invalidExtId)
+      throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    addExtId(allUsersRepo, invalidExtId);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void readExternalIdsWhenInvalidExternalIdsExist() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    insertValidExternalIds();
+    insertInvalidButParsableExternalIds();
+
+    Set<ExternalId> parseableExtIds = externalIds.all();
+
+    insertNonParsableExternalIds();
+
+    Set<ExternalId> extIds = externalIds.all();
+    assertThat(extIds).containsExactlyElementsIn(parseableExtIds);
+
+    for (ExternalId parseableExtId : parseableExtIds) {
+      ExternalId extId = externalIds.get(parseableExtId.key());
+      assertThat(extId).isEqualTo(parseableExtId);
+    }
+  }
+
+  @Test
+  public void checkConsistency() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    insertValidExternalIds();
+
+    ConsistencyCheckInput input = new ConsistencyCheckInput();
+    input.checkAccountExternalIds = new CheckAccountExternalIdsInput();
+    ConsistencyCheckInfo checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).isEmpty();
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    expectedProblems.addAll(insertInvalidButParsableExternalIds());
+    expectedProblems.addAll(insertNonParsableExternalIds());
+
+    checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).hasSize(expectedProblems.size());
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems)
+        .containsExactlyElementsIn(expectedProblems);
+  }
+
+  @Test
+  public void checkConsistencyNotAllowed() throws Exception {
+    exception.expect(AuthException.class);
+    exception.expectMessage("access database not permitted");
+    gApi.config().server().checkConsistency(new ConsistencyCheckInput());
+  }
+
+  private ConsistencyProblemInfo consistencyError(String message) {
+    return new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, message);
+  }
+
+  private void insertValidExternalIds() throws IOException, ConfigInvalidException, OrmException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "valid";
+    ExternalIdsUpdate u = extIdsUpdate.create();
+
+    // create valid external IDs
+    u.insert(
+        ExternalId.createWithPassword(
+            ExternalId.Key.parse(nextId(scheme, i)),
+            admin.id,
+            "admin.other@example.com",
+            "secret-password"));
+    u.insert(createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
+  }
+
+  private Set<ConsistencyProblemInfo> insertInvalidButParsableExternalIds()
+      throws IOException, ConfigInvalidException, OrmException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "invalid";
+    ExternalIdsUpdate u = extIdsUpdate.create();
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    ExternalId extIdForNonExistingAccount =
+        createExternalIdForNonExistingAccount(nextId(scheme, i));
+    u.insert(extIdForNonExistingAccount);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdForNonExistingAccount.key().get()
+                + "' belongs to account that doesn't exist: "
+                + extIdForNonExistingAccount.accountId().get()));
+
+    ExternalId extIdWithInvalidEmail = createExternalIdWithInvalidEmail(nextId(scheme, i));
+    u.insert(extIdWithInvalidEmail);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithInvalidEmail.key().get()
+                + "' has an invalid email: "
+                + extIdWithInvalidEmail.email()));
+
+    ExternalId extIdWithDuplicateEmail = createExternalIdWithDuplicateEmail(nextId(scheme, i));
+    u.insert(extIdWithDuplicateEmail);
+    expectedProblems.add(
+        consistencyError(
+            "Email '"
+                + extIdWithDuplicateEmail.email()
+                + "' is not unique, it's used by the following external IDs: '"
+                + extIdWithDuplicateEmail.key().get()
+                + "', 'mailto:"
+                + extIdWithDuplicateEmail.email()
+                + "'"));
+
+    ExternalId extIdWithBadPassword = createExternalIdWithBadPassword("admin-username");
+    u.insert(extIdWithBadPassword);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithBadPassword.key().get()
+                + "' has an invalid password: unrecognized algorithm"));
+
+    return expectedProblems;
+  }
+
+  private Set<ConsistencyProblemInfo> insertNonParsableExternalIds() throws IOException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "corrupt";
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      String externalId = nextId(scheme, i);
+      String noteId = insertExternalIdWithoutAccountId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Value for 'externalId."
+                  + externalId
+                  + ".accountId' is missing, expected account ID"));
+
+      externalId = nextId(scheme, i);
+      noteId = insertExternalIdWithKeyThatDoesntMatchNoteId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': SHA1 of external ID '"
+                  + externalId
+                  + "' does not match note ID '"
+                  + noteId
+                  + "'"));
+
+      noteId = insertExternalIdWithInvalidConfig(repo, rw, nextId(scheme, i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '" + noteId + "': Invalid line in config file"));
+
+      noteId = insertExternalIdWithEmptyNote(repo, rw, nextId(scheme, i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Expected exactly 1 'externalId' section, found 0"));
+    }
+
+    return expectedProblems;
+  }
+
+  private ExternalId createExternalIdWithOtherCaseEmail(String externalId) {
+    return ExternalId.createWithPassword(
+        ExternalId.Key.parse(externalId), admin.id, admin.email.toUpperCase(Locale.US), "password");
+  }
+
+  private String insertExternalIdWithoutAccountId(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = extId.key().sha1();
+      Config c = new Config();
+      extId.writeToConfig(c);
+      c.unset("externalId", extId.key().get(), "accountId");
+      byte[] raw = c.toText().getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithKeyThatDoesntMatchNoteId(
+      Repository repo, RevWalk rw, String externalId) throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
+      Config c = new Config();
+      extId.writeToConfig(c);
+      byte[] raw = c.toText().getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithInvalidConfig(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+      byte[] raw = "bad-config".getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithEmptyNote(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+      byte[] raw = "".getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
+      return noteId.getName();
+    }
+  }
+
+  private ExternalId createExternalIdForNonExistingAccount(String externalId) {
+    return ExternalId.create(ExternalId.Key.parse(externalId), new Account.Id(1));
+  }
+
+  private ExternalId createExternalIdWithInvalidEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, "invalid-email");
+  }
+
+  private ExternalId createExternalIdWithDuplicateEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, admin.email);
+  }
+
+  private ExternalId createExternalIdWithBadPassword(String username) {
+    return ExternalId.create(
+        ExternalId.Key.create(SCHEME_USERNAME, username),
+        admin.id,
+        null,
+        "non-hashed-password-is-not-allowed");
+  }
+
+  private static String nextId(String scheme, MutableInteger i) {
+    return scheme + ":foo" + ++i.value;
+  }
+
+  @Test
+  public void retryOnLockFailure() throws Exception {
+    Retryer<RefsMetaExternalIdsUpdate> retryer =
+        ExternalIdsUpdate.retryerBuilder()
+            .withBlockStrategy(
+                new BlockStrategy() {
+                  @Override
+                  public void block(long sleepTime) {
+                    // Don't sleep in tests.
+                  }
+                })
+            .build();
+
+    ExternalId.Key fooId = ExternalId.Key.create("foo", "foo");
+    ExternalId.Key barId = ExternalId.Key.create("bar", "bar");
+
+    final AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    ExternalIdsUpdate update =
+        new ExternalIdsUpdate(
+            repoManager,
+            accountCache,
+            allUsers,
+            metricMaker,
+            externalIds,
+            new DisabledExternalIdCache(),
+            serverIdent.get(),
+            serverIdent.get(),
+            null,
+            GitReferenceUpdated.DISABLED,
+            () -> {
+              if (!doneBgUpdate.getAndSet(true)) {
+                try {
+                  extIdsUpdate.create().insert(ExternalId.create(barId, admin.id));
+                } catch (IOException | ConfigInvalidException | OrmException e) {
+                  // Ignore, the successful insertion of the external ID is asserted later
+                }
+              }
+            },
+            retryer);
+    assertThat(doneBgUpdate.get()).isFalse();
+    update.insert(ExternalId.create(fooId, admin.id));
+    assertThat(doneBgUpdate.get()).isTrue();
+
+    assertThat(externalIds.get(fooId)).isNotNull();
+    assertThat(externalIds.get(barId)).isNotNull();
+  }
+
+  @Test
+  public void failAfterRetryerGivesUp() throws Exception {
+    ExternalId.Key[] extIdsKeys = {
+      ExternalId.Key.create("foo", "foo"),
+      ExternalId.Key.create("bar", "bar"),
+      ExternalId.Key.create("baz", "baz")
+    };
+    final AtomicInteger bgCounter = new AtomicInteger(0);
+    ExternalIdsUpdate update =
+        new ExternalIdsUpdate(
+            repoManager,
+            accountCache,
+            allUsers,
+            metricMaker,
+            externalIds,
+            new DisabledExternalIdCache(),
+            serverIdent.get(),
+            serverIdent.get(),
+            null,
+            GitReferenceUpdated.DISABLED,
+            () -> {
+              try {
+                extIdsUpdate
+                    .create()
+                    .insert(ExternalId.create(extIdsKeys[bgCounter.getAndAdd(1)], admin.id));
+              } catch (IOException | ConfigInvalidException | OrmException e) {
+                // Ignore, the successful insertion of the external ID is asserted later
+              }
+            },
+            RetryerBuilder.<RefsMetaExternalIdsUpdate>newBuilder()
+                .retryIfException(e -> e instanceof LockFailureException)
+                .withStopStrategy(StopStrategies.stopAfterAttempt(extIdsKeys.length))
+                .build());
+    assertThat(bgCounter.get()).isEqualTo(0);
+    try {
+      update.insert(ExternalId.create(ExternalId.Key.create("abc", "abc"), admin.id));
+      fail("expected LockFailureException");
+    } catch (LockFailureException e) {
+      // Ignore, expected
+    }
+    assertThat(bgCounter.get()).isEqualTo(extIdsKeys.length);
+    for (ExternalId.Key extIdKey : extIdsKeys) {
+      assertThat(externalIds.get(extIdKey)).isNotNull();
+    }
+  }
+
+  @Test
+  public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
+    ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
+    Account.Id accountId = new Account.Id(1024 * 100);
+    extIdsUpdate.create().insert(ExternalId.create(extIdKey, accountId));
+    ExternalId extId = externalIds.get(extIdKey);
+    assertThat(extId.accountId()).isEqualTo(accountId);
+  }
+
+  @Test
+  public void checkNoReloadAfterUpdate() throws Exception {
+    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id));
+    externalIdReader.setFailOnLoad(true);
+
+    // insert external ID
+    ExternalId extId = ExternalId.create("foo", "bar", admin.id);
+    extIdsUpdate.create().insert(extId);
+    expectedExtIds.add(extId);
+    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+
+    // update external ID
+    expectedExtIds.remove(extId);
+    extId = ExternalId.createWithEmail("foo", "bar", admin.id, "foo.bar@example.com");
+    extIdsUpdate.create().upsert(extId);
+    expectedExtIds.add(extId);
+    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+
+    // delete external ID
+    extIdsUpdate.create().delete(extId);
+    expectedExtIds.remove(extId);
+    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+  }
+
+  @Test
+  public void byAccountFailIfReadingExternalIdsFails() throws Exception {
+    externalIdReader.setFailOnLoad(true);
+
+    // update external ID branch so that external IDs need to be reloaded
+    insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+
+    exception.expect(IOException.class);
+    externalIds.byAccount(admin.id);
+  }
+
+  @Test
+  public void byEmailFailIfReadingExternalIdsFails() throws Exception {
+    externalIdReader.setFailOnLoad(true);
+
+    // update external ID branch so that external IDs need to be reloaded
+    insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+
+    exception.expect(IOException.class);
+    externalIds.byEmail(admin.email);
+  }
+
+  @Test
+  public void byAccountUpdateExternalIdsBehindGerritsBack() throws Exception {
+    Set<ExternalId> expectedExternalIds = new HashSet<>(externalIds.byAccount(admin.id));
+    ExternalId newExtId = ExternalId.create("foo", "bar", admin.id);
+    insertExtIdBehindGerritsBack(newExtId);
+    expectedExternalIds.add(newExtId);
+    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExternalIds);
+  }
+
+  @Test
+  public void unsetEmail() throws Exception {
+    ExternalId extId = ExternalId.createWithEmail("x", "1", user.id, "x@example.com");
+    extIdsUpdate.create().insert(extId);
+
+    ExternalId extIdWithoutEmail = ExternalId.create("x", "1", user.id);
+    extIdsUpdate.create().upsert(extIdWithoutEmail);
+
+    assertThat(externalIds.get(extId.key())).isEqualTo(extIdWithoutEmail);
+  }
+
+  @Test
+  public void unsetHttpPassword() throws Exception {
+    ExternalId extId =
+        ExternalId.createWithPassword(ExternalId.Key.create("y", "1"), user.id, null, "secret");
+    extIdsUpdate.create().insert(extId);
+
+    ExternalId extIdWithoutPassword = ExternalId.create("y", "1", user.id);
+    extIdsUpdate.create().upsert(extIdWithoutPassword);
+
+    assertThat(externalIds.get(extId.key())).isEqualTo(extIdWithoutPassword);
+  }
+
+  private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId rev = ExternalIdReader.readRevision(repo);
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+      ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
+      ExternalIdsUpdate.commit(
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "insert new ID",
+          serverIdent.get(),
+          serverIdent.get(),
+          null,
+          GitReferenceUpdated.DISABLED);
+    }
+  }
+
+  private void addExtId(TestRepository<?> testRepo, ExternalId... extIds)
+      throws IOException, OrmDuplicateKeyException, ConfigInvalidException {
+    ObjectId rev = ExternalIdReader.readRevision(testRepo.getRepository());
+
+    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
+      NoteMap noteMap = ExternalIdReader.readNoteMap(testRepo.getRevWalk(), rev);
+      for (ExternalId extId : extIds) {
+        ExternalIdsUpdate.insert(testRepo.getRevWalk(), ins, noteMap, extId);
+      }
+
+      ExternalIdsUpdate.commit(
+          allUsers,
+          testRepo.getRepository(),
+          testRepo.getRevWalk(),
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
+    }
+  }
+
+  private List<AccountExternalIdInfo> toExternalIdInfos(Collection<ExternalId> extIds) {
+    return extIds.stream().map(this::toExternalIdInfo).collect(toList());
+  }
+
+  private AccountExternalIdInfo toExternalIdInfo(ExternalId extId) {
+    AccountExternalIdInfo info = new AccountExternalIdInfo();
+    info.identity = extId.key().get();
+    info.emailAddress = extId.email();
+    info.canDelete = !extId.isScheme(SCHEME_USERNAME) ? true : null;
+    info.trusted =
+        extId.isScheme(SCHEME_MAILTO)
+                || extId.isScheme(SCHEME_UUID)
+                || extId.isScheme(SCHEME_USERNAME)
+            ? true
+            : null;
+    return info;
+  }
+
+  private void allowPushOfExternalIds() throws IOException, ConfigInvalidException {
+    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.READ);
+    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.PUSH);
+  }
+
+  private void assertRefUpdateFailure(RemoteRefUpdate update, String msg) {
+    assertThat(update.getStatus()).isEqualTo(Status.REJECTED_OTHER_REASON);
+    assertThat(update.getMessage()).contains(msg);
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 54943e7..5004d95 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
@@ -41,6 +42,7 @@
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+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.common.GroupInfo;
@@ -84,7 +86,7 @@
   @Before
   public void setUp() throws Exception {
     anonRestSession = new RestSession(server, null);
-    admin2 = accounts.admin2();
+    admin2 = accountCreator.admin2();
     GroupInput gi = new GroupInput();
     gi.name = name("New-Group");
     gi.members = ImmutableList.of(user.id.toString());
@@ -137,36 +139,29 @@
   }
 
   @Test
+  @GerritConfig(name = "change.strictLabels", value = "true")
   public void voteOnBehalfOfInvalidLabel() throws Exception {
     allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
-    ReviewInput in = new ReviewInput();
+    String changeId = createChange().getChangeId();
+    ReviewInput in = new ReviewInput().label("Not-A-Label", 5);
     in.onBehalfOf = user.id.toString();
-    in.strictLabels = true;
-    in.label("Not-A-Label", 5);
 
     exception.expect(BadRequestException.class);
     exception.expectMessage("label \"Not-A-Label\" is not a configured label");
-    revision.review(in);
+    gApi.changes().id(changeId).current().review(in);
   }
 
   @Test
   public void voteOnBehalfOfInvalidLabelIgnoredWithoutStrictLabels() throws Exception {
     allowCodeReviewOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
-    ReviewInput in = new ReviewInput();
+    String changeId = createChange().getChangeId();
+    ReviewInput in = new ReviewInput().label("Code-Review", 1).label("Not-A-Label", 5);
     in.onBehalfOf = user.id.toString();
-    in.strictLabels = false;
-    in.label("Code-Review", 1);
-    in.label("Not-A-Label", 5);
+    gApi.changes().id(changeId).current().review(in);
 
-    revision.review(in);
-
-    assertThat(gApi.changes().id(r.getChangeId()).get().labels).doesNotContainKey("Not-A-Label");
+    assertThat(gApi.changes().id(changeId).get().labels).doesNotContainKey("Not-A-Label");
   }
 
   @Test
@@ -311,7 +306,7 @@
     in.label("Code-Review", 1);
 
     exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see destination ref");
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
     revision.review(in);
   }
 
@@ -319,7 +314,7 @@
   @Test
   public void voteOnBehalfOfInvisibleUserNotAllowed() throws Exception {
     allowCodeReviewOnBehalfOf();
-    setApiUser(accounts.user2());
+    setApiUser(accountCreator.user2());
     assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
 
     PushOneCommit.Result r = createChange();
@@ -375,7 +370,7 @@
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = admin2.email;
     exception.expect(AuthException.class);
-    exception.expectMessage("submit on behalf of not permitted");
+    exception.expectMessage("submit as not permitted");
     gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
   }
 
@@ -390,7 +385,7 @@
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = user.email;
     exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see destination ref");
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
     gApi.changes().id(changeId).current().submit(in);
   }
 
@@ -398,7 +393,7 @@
   @Test
   public void submitOnBehalfOfInvisibleUserNotAllowed() throws Exception {
     allowSubmitOnBehalfOf();
-    setApiUser(accounts.user2());
+    setApiUser(accountCreator.user2());
     assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
 
     PushOneCommit.Result r = createChange();
@@ -499,7 +494,7 @@
     //   X-Gerrit-RunAs user (user2).
     allowRunAs();
     allowCodeReviewOnBehalfOf();
-    TestAccount user2 = accounts.user2();
+    TestAccount user2 = accountCreator.user2();
 
     PushOneCommit.Result r = createChange();
     ReviewInput in = new ReviewInput();
@@ -529,6 +524,27 @@
     assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
   }
 
+  @Test
+  public void changeMessageCreatedOnBehalfOfHasRealUser() throws Exception {
+    allowCodeReviewOnBehalfOf();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+    in.label("Code-Review", 1);
+
+    setApiUser(accountCreator.user2());
+    gApi.changes().id(r.getChangeId()).revision(r.getPatchSetId().getId()).review(in);
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(MESSAGES);
+    assertThat(info.messages).hasSize(2);
+
+    ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
+    assertThat(changeMessageInfo.realAuthor).isNotNull();
+    assertThat(changeMessageInfo.realAuthor._accountId).isEqualTo(accountCreator.user2().id.get());
+  }
+
   private void allowCodeReviewOnBehalfOf() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     LabelType codeReviewType = Util.codeReview();
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 9378591..7de9d70 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
@@ -18,23 +18,16 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.common.TimeUtil;
-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.SchemaFactory;
-import com.google.inject.Inject;
-import java.util.Collections;
 import org.junit.Test;
 
 public class PutUsernameIT extends AbstractDaemonTest {
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-
   @Test
   public void set() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = "myUsername";
-    RestResponse r = adminRestSession.put("/accounts/" + createUser().get() + "/username", in);
+    RestResponse r =
+        adminRestSession.put("/accounts/" + accountCreator.create().id.get() + "/username", in);
     r.assertOK();
     assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(in.username);
   }
@@ -43,7 +36,9 @@
   public void setExisting_Conflict() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = admin.username;
-    adminRestSession.put("/accounts/" + createUser().get() + "/username", in).assertConflict();
+    adminRestSession
+        .put("/accounts/" + accountCreator.create().id.get() + "/username", in)
+        .assertConflict();
   }
 
   @Test
@@ -57,13 +52,4 @@
   public void delete_MethodNotAllowed() throws Exception {
     adminRestSession.put("/accounts/" + admin.username + "/username").assertMethodNotAllowed();
   }
-
-  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());
-      db.accounts().insert(Collections.singleton(a));
-      return id;
-    }
-  }
 }
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 7d55c66..682b5bc 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
@@ -16,7 +16,7 @@
 
 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.Truth8.assertThat;
 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;
@@ -32,6 +32,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 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;
@@ -40,6 +41,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -65,6 +67,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.Submit;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -79,6 +82,7 @@
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -95,6 +99,8 @@
 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.transport.RefSpec;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -112,8 +118,6 @@
 
   @Inject private IdentifiedUser.GenericFactory userFactory;
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
   private RegistrationHandle onSubmitValidatorHandle;
 
@@ -306,7 +310,7 @@
   public void submitNoPermission() throws Exception {
     // create project where submit is blocked
     Project.NameKey p = createProject("p");
-    block(Permission.SUBMIT, REGISTERED_USERS, "refs/*", p);
+    block(p, "refs/*", Permission.SUBMIT, REGISTERED_USERS);
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
@@ -475,37 +479,59 @@
     assertThat(commitsInRepo)
         .containsAllOf("Initial empty repository", "Change 1", "Change 2", "Change 3");
     if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
-      assertThat(commitsInRepo).contains("Merge changes from topic '" + expectedTopic + "'");
+      assertThat(commitsInRepo).contains("Merge changes from topic \"" + expectedTopic + "\"");
     }
   }
 
   @Test
-  public void submitDraftChange() throws Exception {
-    PushOneCommit.Result draft = createDraftChange();
-    Change.Id num = draft.getChange().getId();
+  public void submitReusingOldTopic() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    String topic = "test-topic";
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "content", topic);
+    String id1 = change1.getChangeId();
+    String id2 = change2.getChangeId();
+    approve(id1);
+    approve(id2);
+    assertSubmittedTogether(id1, ImmutableList.of(id1, id2));
+    assertSubmittedTogether(id2, ImmutableList.of(id1, id2));
+    submit(id2);
+
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    assertSubmittedTogether(id1, ImmutableList.of(id1, id2));
+    assertSubmittedTogether(id2, ImmutableList.of(id1, id2));
+
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "content", topic);
+    String id3 = change3.getChangeId();
+    approve(id3);
+    assertSubmittedTogether(id3, ImmutableList.of());
+    submit(id3);
+
+    change3.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    assertSubmittedTogether(id3, ImmutableList.of());
+  }
+
+  private void assertSubmittedTogether(String changeId, Iterable<String> expected)
+      throws Exception {
+    assertThat(gApi.changes().id(changeId).submittedTogether().stream().map(i -> i.changeId))
+        .containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void submitWorkInProgressChange() throws Exception {
+    PushOneCommit.Result change = createWorkInProgressChange();
+    Change.Id num = change.getChange().getId();
     submitWithConflict(
-        draft.getChangeId(),
+        change.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");
+            + " is work in progress");
   }
 
   @Test
@@ -756,11 +782,16 @@
         new OnSubmitValidationListener() {
           @Override
           public void preBranchUpdate(Arguments args) throws ValidationException {
-            assertThat(args.getCommands().keySet()).contains("refs/heads/master");
-            try (RevWalk rw = args.newRevWalk()) {
-              rw.parseBody(rw.parseCommit(args.getCommands().get("refs/heads/master").getNewId()));
+            String master = "refs/heads/master";
+            assertThat(args.getCommands()).containsKey(master);
+            ReceiveCommand cmd = args.getCommands().get(master);
+            ObjectId newMasterId = cmd.getNewId();
+            try (Repository repo = repoManager.openRepository(args.getProject())) {
+              assertThat(repo.exactRef(master).getObjectId()).isEqualTo(cmd.getOldId());
+              assertThat(args.getRef(master)).hasValue(newMasterId);
+              args.getRevWalk().parseBody(args.getRevWalk().parseCommit(newMasterId));
             } catch (IOException e) {
-              throw new ValidationException("Unexpected exception", e);
+              throw new AssertionError("failed checking new ref value", e);
             }
             projectsCalled.add(args.getProject().get());
             if (projectsCalled.size() == 2) {
@@ -783,10 +814,181 @@
     }
   }
 
+  @Test
+  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    RevCommit initialHead = getRemoteHead();
+
+    // Create a stable branch and bootstrap it.
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
+    PushOneCommit push =
+        pushFactory.create(db, user.getIdent(), testRepo, "initial commit", "a.txt", "a");
+    PushOneCommit.Result change = push.to("refs/heads/stable");
+
+    RevCommit stable = getRemoteHead(project, "stable");
+    RevCommit master = getRemoteHead(project, "master");
+
+    assertThat(master).isEqualTo(initialHead);
+    assertThat(stable).isEqualTo(change.getCommit());
+
+    testRepo.git().fetch().call();
+    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
+    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
+
+    // Create a fix in stable branch.
+    testRepo.reset(stable);
+    RevCommit fix =
+        testRepo
+            .commit()
+            .parent(stable)
+            .message("small fix")
+            .add("b.txt", "b")
+            .insertChangeId()
+            .create();
+    testRepo.branch("refs/heads/stable").update(fix);
+    testRepo
+        .git()
+        .push()
+        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable/" + name("topic")))
+        .call();
+
+    // Merge the fix into master.
+    testRepo.reset(master);
+    RevCommit merge =
+        testRepo
+            .commit()
+            .parent(master)
+            .parent(fix)
+            .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/" + name("topic")))
+        .call();
+
+    // Submit together.
+    String fixId = GitUtil.getChangeId(testRepo, fix).get();
+    String mergeId = GitUtil.getChangeId(testRepo, merge).get();
+    approve(fixId);
+    approve(mergeId);
+    submit(mergeId);
+    assertMerged(fixId);
+    assertMerged(mergeId);
+    testRepo.git().fetch().call();
+    RevWalk rw = testRepo.getRevWalk();
+    master = rw.parseCommit(getRemoteHead(project, "master"));
+    assertThat(rw.isMergedInto(merge, master)).isTrue();
+    assertThat(rw.isMergedInto(fix, master)).isTrue();
+  }
+
+  @Test
+  public void retrySubmitSingleChangeOnLockFailure() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+
+    PushOneCommit.Result change = createChange();
+    String id = change.getChangeId();
+    approve(id);
+
+    TestSubmitInput input = new TestSubmitInput();
+    input.generateLockFailures =
+        new ArrayDeque<>(
+            ImmutableList.of(
+                true, // Attempt 1: lock failure
+                false, // Attempt 2: success
+                false)); // Leftover value to check total number of calls.
+    submit(id, input);
+    assertMerged(id);
+
+    testRepo.git().fetch().call();
+    RevWalk rw = testRepo.getRevWalk();
+    RevCommit master = rw.parseCommit(getRemoteHead(project, "master"));
+    RevCommit patchSet = parseCurrentRevision(rw, change);
+    assertThat(rw.isMergedInto(patchSet, master)).isTrue();
+
+    assertThat(input.generateLockFailures).containsExactly(false);
+  }
+
+  @Test
+  public void retrySubmitAfterTornTopicOnLockFailure() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    String topic = "test-topic";
+
+    TestRepository<?> repoA = createProjectWithPush("project-a", null, getSubmitType());
+    TestRepository<?> repoB = createProjectWithPush("project-b", null, getSubmitType());
+
+    PushOneCommit.Result change1 =
+        createChange(repoA, "master", "Change 1", "a.txt", "content", topic);
+    PushOneCommit.Result change2 =
+        createChange(repoB, "master", "Change 2", "b.txt", "content", topic);
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+
+    TestSubmitInput input = new TestSubmitInput();
+    input.generateLockFailures =
+        new ArrayDeque<>(
+            ImmutableList.of(
+                false, // Change 1, attempt 1: success
+                true, // Change 2, attempt 1: lock failure
+                false, // Change 1, attempt 2: success
+                false, // Change 2, attempt 2: success
+                false)); // Leftover value to check total number of calls.
+    submit(change2.getChangeId(), input);
+
+    String expectedTopic = name(topic);
+    change1.assertChange(Change.Status.MERGED, expectedTopic, admin);
+    change2.assertChange(Change.Status.MERGED, expectedTopic, admin);
+
+    repoA.git().fetch().call();
+    RevWalk rwA = repoA.getRevWalk();
+    RevCommit masterA = rwA.parseCommit(getRemoteHead(name("project-a"), "master"));
+    RevCommit change1Ps = parseCurrentRevision(rwA, change1);
+    assertThat(rwA.isMergedInto(change1Ps, masterA)).isTrue();
+
+    repoB.git().fetch().call();
+    RevWalk rwB = repoB.getRevWalk();
+    RevCommit masterB = rwB.parseCommit(getRemoteHead(name("project-b"), "master"));
+    RevCommit change2Ps = parseCurrentRevision(rwB, change2);
+    assertThat(rwB.isMergedInto(change2Ps, masterB)).isTrue();
+
+    assertThat(input.generateLockFailures).containsExactly(false);
+  }
+
+  @Test
+  public void authorAndCommitDateAreEqual() throws Exception {
+    assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
+
+    ConfigInput ci = new ConfigInput();
+    ci.matchAuthorToCommitterDate = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(ci);
+
+    RevCommit initialHead = getRemoteHead();
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change = createChange("Change 1", "b", "b");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("Change 2", "c", "c");
+
+    if (getSubmitType() == SubmitType.MERGE_IF_NECESSARY
+        || getSubmitType() == SubmitType.REBASE_IF_NECESSARY) {
+      // Merge another change so that change2 is not a fast-forward
+      submit(change.getChangeId());
+    }
+
+    submit(change2.getChangeId());
+    assertAuthorAndCommitDateEquals(getRemoteHead());
+  }
+
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
-          updateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
+          batchUpdateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
         bu.addOp(
             change.getChange().getId(),
             new BatchUpdateOp() {
@@ -936,6 +1138,12 @@
     assertThat(actual.getTimeZone()).isEqualTo(expected.getTimeZone());
   }
 
+  protected void assertAuthorAndCommitDateEquals(RevCommit commit) {
+    assertThat(commit.getAuthorIdent().getWhen()).isEqualTo(commit.getCommitterIdent().getWhen());
+    assertThat(commit.getAuthorIdent().getTimeZone())
+        .isEqualTo(commit.getCommitterIdent().getTimeZone());
+  }
+
   protected void assertSubmitter(String changeId, int psId) throws Exception {
     assertSubmitter(changeId, psId, admin);
   }
@@ -970,10 +1178,7 @@
     Repository repo = testRepo.getRepository();
     RevCommit localHead = getHead(repo);
     RevCommit remoteHead = getRemoteHead();
-    assert_()
-        .withFailureMessage(String.format("%s not equal %s", localHead.name(), remoteHead.name()))
-        .that(localHead.getId())
-        .isNotEqualTo(remoteHead.getId());
+    assertThat(localHead.getId()).isNotEqualTo(remoteHead.getId());
     assertThat(remoteHead.getParentCount()).isEqualTo(1);
     if (!contentMerge) {
       assertThat(getLatestRemoteDiff()).isEqualTo(getLatestDiff(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 0250db1..b4d8557 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
@@ -18,11 +18,9 @@
 import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.Iterables;
-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.SubmitInput;
-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;
@@ -34,7 +32,6 @@
 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.Test;
 
 public abstract class AbstractSubmitByMerge extends AbstractSubmit {
@@ -130,6 +127,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -138,10 +138,11 @@
     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);
+    TestSubmitInput failInput = new TestSubmitInput();
+    failInput.failAfterRefUpdates = true;
     submit(
         change2.getChangeId(),
-        failAfterRefUpdates,
+        failInput,
         ResourceConflictException.class,
         "Failing after ref updates");
 
@@ -177,75 +178,4 @@
       assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(tip);
     }
   }
-
-  @Test
-  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-
-    RevCommit initialHead = getRemoteHead();
-
-    // Create a stable branch and bootstrap it.
-    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
-    PushOneCommit push =
-        pushFactory.create(db, user.getIdent(), testRepo, "initial commit", "a.txt", "a");
-    PushOneCommit.Result change = push.to("refs/heads/stable");
-
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
-
-    assertThat(master).isEqualTo(initialHead);
-    assertThat(stable).isEqualTo(change.getCommit());
-
-    testRepo.git().fetch().call();
-    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
-    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
-
-    // Create a fix in stable branch.
-    testRepo.reset(stable);
-    RevCommit fix =
-        testRepo
-            .commit()
-            .parent(stable)
-            .message("small fix")
-            .add("b.txt", "b")
-            .insertChangeId()
-            .create();
-    testRepo.branch("refs/heads/stable").update(fix);
-    testRepo
-        .git()
-        .push()
-        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable/" + name("topic")))
-        .call();
-
-    // Merge the fix into master.
-    testRepo.reset(master);
-    RevCommit merge =
-        testRepo
-            .commit()
-            .parent(master)
-            .parent(fix)
-            .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/" + name("topic")))
-        .call();
-
-    // Submit together.
-    String fixId = GitUtil.getChangeId(testRepo, fix).get();
-    String mergeId = GitUtil.getChangeId(testRepo, merge).get();
-    approve(fixId);
-    approve(mergeId);
-    submit(mergeId);
-    assertMerged(fixId);
-    assertMerged(mergeId);
-    testRepo.git().fetch().call();
-    RevWalk rw = testRepo.getRevWalk();
-    master = rw.parseCommit(getRemoteHead(project, "master"));
-    assertThat(rw.isMergedInto(merge, master)).isTrue();
-    assertThat(rw.isMergedInto(fix, master)).isTrue();
-  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index d8aa35c..5dfc76d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -15,6 +15,7 @@
 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.acceptance.GitUtil.getChangeId;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -25,7 +26,6 @@
 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;
@@ -244,6 +244,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -252,10 +255,11 @@
     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);
+    TestSubmitInput failInput = new TestSubmitInput();
+    failInput.failAfterRefUpdates = true;
     submit(
         change2.getChangeId(),
-        failAfterRefUpdates,
+        failInput,
         ResourceConflictException.class,
         "Failing after ref updates");
     RevCommit headAfterFailedSubmit = getRemoteHead();
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 87436e7..72be321 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
@@ -15,13 +15,13 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 
 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.TestProjectInput;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testutil.ConfigSuite;
@@ -41,8 +42,6 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
 import org.junit.Before;
@@ -141,28 +140,6 @@
   }
 
   @Test
-  public void revisionActionsETagWithHiddenDraftInTopic() throws Exception {
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-
-    setApiUser(user);
-    String etag1 = getETag(change);
-
-    setApiUser(admin);
-    String draft = createDraftWithTopic().getChangeId();
-    approve(draft);
-
-    setApiUser(user);
-    String etag2 = getETag(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();
@@ -223,8 +200,9 @@
 
     // create another change with the same topic
     String changeId2 =
-        createChangeWithTopic(testRepo, "foo2", "touching b", "b.txt", "real content")
+        createChangeWithTopic(testRepo, "topic", "touching b", "b.txt", "real content")
             .getChangeId();
+    int changeNum2 = gApi.changes().id(changeId2).info()._number;
     approve(changeId2);
 
     // collide with the other change in the same topic
@@ -243,7 +221,7 @@
       assertThat(info.enabled).isNull();
       assertThat(info.label).isEqualTo("Submit whole topic");
       assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).isEqualTo("Problems with change(s): 2");
+      assertThat(info.title).isEqualTo("Problems with change(s): " + changeNum2);
     } else {
       noSubmitWholeTopicAssertions(actions, 1);
     }
@@ -314,7 +292,7 @@
   @Test
   public void changeActionVisitor() throws Exception {
     String id = createChange().getChangeId();
-    ChangeInfo origChange = gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS));
+    ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
 
     class Visitor implements ActionVisitor {
       @Override
@@ -357,9 +335,11 @@
   }
 
   @Test
-  public void revisionActionVisitor() throws Exception {
+  public void currentRevisionActionVisitor() throws Exception {
     String id = createChange().getChangeId();
-    ChangeInfo origChange = gApi.changes().id(id).get(EnumSet.of(ListChangesOption.CHANGE_ACTIONS));
+    amendChange(id);
+    ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
+    Change.Id changeId = new Change.Id(origChange._number);
 
     class Visitor implements ActionVisitor {
       @Override
@@ -373,7 +353,7 @@
         assertThat(changeInfo).isNotNull();
         assertThat(changeInfo._number).isEqualTo(origChange._number);
         assertThat(revisionInfo).isNotNull();
-        assertThat(revisionInfo._number).isEqualTo(1);
+        assertThat(revisionInfo._number).isEqualTo(2);
         if (name.equals("cherrypick")) {
           return false;
         }
@@ -393,24 +373,23 @@
 
     // Test different codepaths within ActionJson...
     // ...via revision API.
-    visitedRevisionActionsAssertions(origActions, gApi.changes().id(id).current().actions());
+    visitedCurrentRevisionActionsAssertions(origActions, gApi.changes().id(id).current().actions());
 
     // ...via change API with option.
     EnumSet<ListChangesOption> opts = EnumSet.of(CURRENT_ACTIONS, CURRENT_REVISION);
     ChangeInfo changeInfo = gApi.changes().id(id).get(opts);
     RevisionInfo revisionInfo = Iterables.getOnlyElement(changeInfo.revisions.values());
-    visitedRevisionActionsAssertions(origActions, revisionInfo.actions);
+    visitedCurrentRevisionActionsAssertions(origActions, revisionInfo.actions);
 
     // ...via ChangeJson directly.
-    ChangeData cd = changeDataFactory.create(db, project, new Change.Id(origChange._number));
+    ChangeData cd = changeDataFactory.create(db, project, changeId);
     revisionInfo =
         changeJsonFactory
             .create(opts)
-            .getRevisionInfo(cd.changeControl(), Iterables.getOnlyElement(cd.patchSets()));
-    visitedRevisionActionsAssertions(origActions, revisionInfo.actions);
+            .getRevisionInfo(cd, cd.patchSet(new PatchSet.Id(changeId, 1)));
   }
 
-  private void visitedRevisionActionsAssertions(
+  private void visitedCurrentRevisionActionsAssertions(
       Map<String, ActionInfo> origActions, Map<String, ActionInfo> newActions) {
     assertThat(newActions).isNotNull();
     Set<String> expectedNames = new TreeSet<>(origActions.keySet());
@@ -422,6 +401,50 @@
     assertThat(rebase.label).isEqualTo("All Your Base");
   }
 
+  @Test
+  public void oldRevisionActionVisitor() throws Exception {
+    String id = createChange().getChangeId();
+    amendChange(id);
+    ChangeInfo origChange = gApi.changes().id(id).get(CHANGE_ACTIONS);
+
+    class Visitor implements ActionVisitor {
+      @Override
+      public boolean visit(String name, ActionInfo actionInfo, ChangeInfo changeInfo) {
+        return true; // Do nothing; implicitly called for CURRENT_ACTIONS.
+      }
+
+      @Override
+      public boolean visit(
+          String name, ActionInfo actionInfo, ChangeInfo changeInfo, RevisionInfo revisionInfo) {
+        assertThat(changeInfo).isNotNull();
+        assertThat(changeInfo._number).isEqualTo(origChange._number);
+        assertThat(revisionInfo).isNotNull();
+        assertThat(revisionInfo._number).isEqualTo(1);
+        if (name.equals("description")) {
+          actionInfo.label = "Describify";
+        }
+        return true;
+      }
+    }
+
+    Map<String, ActionInfo> origActions = gApi.changes().id(id).revision(1).actions();
+    assertThat(origActions.keySet()).containsExactly("description");
+    assertThat(origActions.get("description").label).isEqualTo("Edit Description");
+
+    Visitor v = new Visitor();
+    visitorHandle = actionVisitors.add(v);
+
+    // Unlike for the current revision, actions for old revisions are only available via the
+    // revision API.
+    Map<String, ActionInfo> newActions = gApi.changes().id(id).revision(1).actions();
+    assertThat(newActions).isNotNull();
+    assertThat(newActions.keySet()).isEqualTo(origActions.keySet());
+
+    ActionInfo description = newActions.get("description");
+    assertThat(description).isNotNull();
+    assertThat(description.label).isEqualTo("Describify");
+  }
+
   private void commonActionsAssertions(Map<String, ActionInfo> actions) {
     assertThat(actions).hasSize(4);
     assertThat(actions).containsKey("cherrypick");
@@ -429,35 +452,4 @@
     assertThat(actions).containsKey("description");
     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 {
-    assertThat(topic).isNotEmpty();
-    return createCommitAndPush(
-        repo, "refs/for/master/" + name(topic), commitMsg, fileName, content);
-  }
-
-  private PushOneCommit.Result createChangeWithTopic() throws Exception {
-    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/AssigneeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 35ba1a2..a905d38 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -16,19 +16,26 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 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.Sandboxed;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
 import java.util.Iterator;
 import java.util.List;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -124,6 +131,43 @@
     assertThat(deleteAssignee(r)).isNull();
   }
 
+  @Test
+  @Sandboxed
+  public void setAssigneeToInactiveUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.accounts().id(user.getId().get()).setActive(false);
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("is not active");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeForNonVisibleChange() throws Exception {
+    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
+    testRepo.reset(RefNames.REFS_CONFIG);
+    PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
+    exception.expect(AuthException.class);
+    exception.expectMessage("read not permitted");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not permitted");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    grant(project, "refs/heads/master", Permission.EDIT_ASSIGNEE, false, REGISTERED_USERS);
+    setApiUser(user);
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+  }
+
   private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
     return gApi.changes().id(r.getChange().getId().get()).getAssignee();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
new file mode 100644
index 0000000..a2ad7fc
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
@@ -0,0 +1,115 @@
+// 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.acceptance.rest.change;
+
+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 org.junit.Test;
+
+public class ChangeIdIT extends AbstractDaemonTest {
+
+  @Test
+  public void projectChangeNumberReturnsChange() throws Exception {
+    PushOneCommit.Result c = createChange();
+    RestResponse res = adminRestSession.get(changeDetail(getProjectChangeNumber(c.getChangeId())));
+    res.assertOK();
+  }
+
+  @Test
+  public void wrongProjectChangeNumberReturnsNotFound() throws Exception {
+    PushOneCommit.Result c = createChange();
+    RestResponse res1 =
+        adminRestSession.get(
+            changeDetail("unknown/project~" + getNumericChangeId(c.getChangeId())));
+    res1.assertNotFound();
+
+    RestResponse res2 = adminRestSession.get(project.get() + "~" + Integer.MAX_VALUE);
+    res2.assertNotFound();
+
+    // Try a non-numeric change number
+    RestResponse res3 = adminRestSession.get(project.get() + "~some-id");
+    res3.assertNotFound();
+  }
+
+  @Test
+  public void changeNumberReturnsChange() throws Exception {
+    PushOneCommit.Result c = createChange();
+    RestResponse res = adminRestSession.get(changeDetail(getNumericChangeId(c.getChangeId())));
+    res.assertOK();
+  }
+
+  @Test
+  public void wrongChangeNumberReturnsNotFound() throws Exception {
+    RestResponse res = adminRestSession.get(changeDetail(String.valueOf(Integer.MAX_VALUE)));
+    res.assertNotFound();
+  }
+
+  @Test
+  public void tripletChangeIdReturnsChange() throws Exception {
+    PushOneCommit.Result c = createChange();
+    RestResponse res = adminRestSession.get(changeDetail(getTriplet(c.getChangeId())));
+    res.assertOK();
+  }
+
+  @Test
+  public void wrongTripletChangeIdReturnsNotFound() throws Exception {
+    PushOneCommit.Result c = createChange();
+    RestResponse res1 = adminRestSession.get(changeDetail("unknown~master~" + c.getChangeId()));
+    res1.assertNotFound();
+
+    RestResponse res2 =
+        adminRestSession.get(changeDetail(project.get() + "~unknown~" + c.getChangeId()));
+    res2.assertNotFound();
+
+    RestResponse res3 = adminRestSession.get(changeDetail(project.get() + "~master~I1234567890"));
+    res3.assertNotFound();
+  }
+
+  @Test
+  public void changeIdReturnsChange() throws Exception {
+    PushOneCommit.Result c = createChange();
+    RestResponse res = adminRestSession.get(changeDetail(c.getChangeId()));
+    res.assertOK();
+  }
+
+  @Test
+  public void wrongChangeIdReturnsNotFound() throws Exception {
+    RestResponse res = adminRestSession.get(changeDetail("I1234567890"));
+    res.assertNotFound();
+  }
+
+  private static String changeDetail(String changeId) {
+    return "/changes/" + changeId + "/detail";
+  }
+
+  /** Convert a changeId (I0...01) to project~changeNumber (project~00001) */
+  private String getProjectChangeNumber(String changeId) throws Exception {
+    ChangeApi cApi = gApi.changes().id(changeId);
+    return cApi.get().project + "~" + cApi.get()._number;
+  }
+
+  /** Convert a changeId (I0...01) to a triplet (project~branch~I0...01) */
+  private String getTriplet(String changeId) throws Exception {
+    ChangeApi cApi = gApi.changes().id(changeId);
+    return cApi.get().project + "~" + cApi.get().branch + "~" + changeId;
+  }
+
+  /** Convert a changeId (I0...01) to a numeric changeId (00001) */
+  private String getNumericChangeId(String changeId) throws Exception {
+    return Integer.toString(gApi.changes().id(changeId).get()._number);
+  }
+}
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 6a00d59..ac0d0aa 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
@@ -36,7 +36,7 @@
   @Before
   public void setUp() throws Exception {
     setApiUser(user);
-    user2 = accounts.user2();
+    user2 = accountCreator.user2();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
new file mode 100644
index 0000000..f4526e5
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.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.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.ListChangesOption.DETAILED_LABELS;
+
+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.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+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.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+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.server.mail.Address;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeReviewersByEmailIT extends AbstractDaemonTest {
+
+  @Before
+  public void setUp() throws Exception {
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+  }
+
+  @Test
+  public void addByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
+      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      // All reviewers added by email should be removable
+      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(acc));
+    }
+  }
+
+  @Test
+  public void addByEmailAndById() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo byEmail = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo byId = new AccountInfo(user.id.get());
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput inputByEmail = new AddReviewerInput();
+      inputByEmail.reviewer = toRfcAddressString(byEmail);
+      inputByEmail.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(inputByEmail);
+
+      AddReviewerInput inputById = new AddReviewerInput();
+      inputById.reviewer = user.email;
+      inputById.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(inputById);
+
+      ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
+      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(byId, byEmail)));
+      // All reviewers (both by id and by email) should be removable
+      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(byId, byEmail));
+    }
+  }
+
+  @Test
+  public void removeByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      gApi.changes().id(r.getChangeId()).reviewer(acc.email).remove();
+
+      ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
+      assertThat(info.reviewers).isEmpty();
+    }
+  }
+
+  @Test
+  public void convertFromCCToReviewer() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput addInput = new AddReviewerInput();
+    addInput.reviewer = toRfcAddressString(acc);
+    addInput.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+    AddReviewerInput modifyInput = new AddReviewerInput();
+    modifyInput.reviewer = addInput.reviewer;
+    modifyInput.state = ReviewerState.REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(modifyInput);
+
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
+    assertThat(info.reviewers)
+        .isEqualTo(ImmutableMap.of(ReviewerState.REVIEWER, ImmutableList.of(acc)));
+  }
+
+  @Test
+  public void addedReviewersGetNotified() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt()).containsExactly(Address.parse(input.reviewer));
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void removingReviewerTriggersNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      // Review change as user
+      ReviewInput reviewInput = new ReviewInput();
+      reviewInput.message = "I have a comment";
+      setApiUser(user);
+      revision(r).review(reviewInput);
+      setApiUser(admin);
+
+      sender.clear();
+
+      // Delete as admin
+      gApi.changes().id(r.getChangeId()).reviewer(addInput.reviewer).remove();
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt())
+          .containsExactly(Address.parse(addInput.reviewer), user.emailAddress);
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void reviewerAndCCReceiveRegularNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+      sender.clear();
+
+      gApi.changes()
+          .id(r.getChangeId())
+          .revision(r.getCommit().name())
+          .review(ReviewInput.approve());
+
+      assertNotifyCc(Address.parse(input.reviewer));
+    }
+  }
+
+  @Test
+  public void reviewerAndCCReceiveSameEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      for (int i = 0; i < 10; i++) {
+        AddReviewerInput input = new AddReviewerInput();
+        input.reviewer = String.format("%s-%s@gerritcodereview.com", state, i);
+        input.state = state;
+        gApi.changes().id(r.getChangeId()).addReviewer(input);
+      }
+    }
+
+    // Also add user as a regular reviewer
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email;
+    input.state = ReviewerState.REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    // Assert that only one email was sent out to everyone
+    assertThat(sender.getMessages()).hasSize(1);
+  }
+
+  @Test
+  public void addingMultipleReviewersAndCCsAtOnceSendsOnlyOneEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput();
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      for (int i = 0; i < 10; i++) {
+        reviewInput.reviewer(String.format("%s-%s@gerritcodereview.com", state, i), state, true);
+      }
+    }
+    assertThat(reviewInput.reviewers).hasSize(20);
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+  }
+
+  @Test
+  public void rejectMissingEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
+    assertThat(result.error).isEqualTo(" is not a valid user identifier");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectMalformedEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
+    assertThat(result.error).isEqualTo("Foo Bar <foo.bar@ is not a valid user identifier");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectWhenFeatureIsDisabled() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.FALSE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result =
+        gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
+    assertThat(result.error)
+        .isEqualTo(
+            "Foo Bar <foo.bar@gerritcodereview.com> does not identify a registered user or group");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void reviewersByEmailAreServedFromIndex() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      notesMigration.setFailOnLoadForTest(true);
+      try {
+        ChangeInfo info =
+            Iterables.getOnlyElement(
+                gApi.changes().query(r.getChangeId()).withOption(DETAILED_LABELS).get());
+        assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      } finally {
+        notesMigration.setFailOnLoadForTest(false);
+      }
+    }
+  }
+
+  private static String toRfcAddressString(AccountInfo info) {
+    return (new Address(info.name, info.email)).toString();
+  }
+}
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
index 66966c3..76b7646 100644
--- 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
@@ -16,18 +16,27 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 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 com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 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.common.data.Permission;
 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.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -36,6 +45,8 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
@@ -192,7 +203,7 @@
 
     // CC a group that overlaps with some existing reviewers and CCed accounts.
     TestAccount reviewer =
-        accounts.create(name("reviewer"), "addCcGroup-reviewer@example.com", "Reviewer");
+        accountCreator.create(name("reviewer"), "addCcGroup-reviewer@example.com", "Reviewer");
     result = addReviewer(changeId, reviewer.username);
     assertThat(result.error).isNull();
     sender.clear();
@@ -418,7 +429,7 @@
 
   @Test
   public void reviewAndAddReviewers() throws Exception {
-    TestAccount observer = accounts.user2();
+    TestAccount observer = accountCreator.user2();
     PushOneCommit.Result r = createChange();
     ReviewInput input =
         ReviewInput.approve().reviewer(user.email).reviewer(observer.email, CC, false);
@@ -473,7 +484,7 @@
         .id(mediumGroup)
         .addMembers(usernames.subList(0, mediumGroupSize).toArray(new String[mediumGroupSize]));
 
-    TestAccount observer = accounts.user2();
+    TestAccount observer = accountCreator.user2();
     PushOneCommit.Result r = createChange();
 
     // Attempt to add overly large group as reviewers.
@@ -603,9 +614,12 @@
   @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");
+    TestAccount user1 =
+        accountCreator.create(name("user1"), emailPrefix + "user1@example.com", "User1");
+    TestAccount user2 =
+        accountCreator.create(name("user2"), emailPrefix + "user2@example.com", "User2");
+    TestAccount user3 =
+        accountCreator.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);
@@ -655,6 +669,128 @@
     assertThat(reviewerResult.ccs).hasSize(1);
   }
 
+  @Test
+  public void removingReviewerRemovesTheirVote() throws Exception {
+    String crLabel = "Code-Review";
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve().reviewer(admin.email);
+    ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(addResult.reviewers).isNotNull();
+    assertThat(addResult.reviewers).hasSize(1);
+
+    Map<String, LabelInfo> changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).hasSize(1);
+
+    RestResponse deleteResult = deleteReviewer(r.getChangeId(), admin);
+    deleteResult.assertNoContent();
+
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+
+    // Check that the vote is gone even after the reviewer is added back
+    addReviewer(r.getChangeId(), admin.email);
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+  }
+
+  @Test
+  public void notifyDetailsWorkOnPostReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount userToNotify = createAccounts(1, "notify-details-post-review").get(0);
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(user.email, ReviewerState.REVIEWER, true);
+    reviewInput.notify = NotifyHandling.NONE;
+    reviewInput.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+  }
+
+  @Test
+  public void notifyDetailsWorkOnPostReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount userToNotify = createAccounts(1, "notify-details-post-reviewers").get(0);
+
+    AddReviewerInput addReviewer = new AddReviewerInput();
+    addReviewer.reviewer = user.email;
+    addReviewer.notify = NotifyHandling.NONE;
+    addReviewer.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).addReviewer(addReviewer);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+  }
+
+  @Test
+  public void removeReviewerWithVoteWithoutPermissionFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Code-Review", 1));
+    setApiUser(newUser);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+  }
+
+  @Test
+  @Sandboxed
+  public void removeReviewerWithoutVoteWithPermissionSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
+    // rather than bypassing the check because of project or ref ownership.
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+    grant(project, RefNames.REFS + "*", Permission.REMOVE_REVIEWER, false, REGISTERED_USERS);
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    assertThatUserIsOnlyReviewer(r.getChangeId());
+    setApiUser(newUser);
+    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+  }
+
+  @Test
+  public void removeReviewerWithoutVoteWithoutPermissionFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    setApiUser(newUser);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+  }
+
+  @Test
+  public void removeCCWithoutPermissionFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email;
+    input.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(input);
+    setApiUser(newUser);
+    exception.expect(AuthException.class);
+    exception.expectMessage("remove reviewer not permitted");
+    gApi.changes().id(r.getChangeId()).reviewer(user.email).remove();
+  }
+
+  private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
+    AccountInfo userInfo = new AccountInfo(user.fullName, user.emailAddress.getEmail());
+    userInfo._accountId = user.id.get();
+    userInfo.username = user.username;
+    assertThat(gApi.changes().id(changeId).get().reviewers)
+        .containsExactly(ReviewerState.REVIEWER, ImmutableList.of(userInfo));
+  }
+
   private AddReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
     return addReviewer(changeId, reviewer, SC_OK);
   }
@@ -694,9 +830,10 @@
   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);
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      jsonReader.setLenient(true);
+      return newGson().fromJson(jsonReader, clazz);
+    }
   }
 
   private static void assertReviewers(
@@ -731,8 +868,13 @@
     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));
+          accountCreator.create(
+              name("u" + i), emailPrefix + "-" + i + "@example.com", "Full Name " + i));
     }
     return result;
   }
+
+  private Map<String, LabelInfo> getChangeLabels(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(DETAILED_LABELS).labels;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java
index 4f2d2bd..861a22c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -18,18 +18,36 @@
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.net.HttpHeaders.VARY;
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.StringSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.testutil.ConfigSuite;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.stream.Stream;
 import org.apache.http.Header;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.fluent.Executor;
 import org.apache.http.client.fluent.Request;
+import org.apache.http.cookie.Cookie;
+import org.apache.http.impl.client.BasicCookieStore;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
@@ -38,6 +56,7 @@
   @ConfigSuite.Default
   public static Config allowExampleDotCom() {
     Config cfg = new Config();
+    cfg.setString("auth", null, "type", "DEVELOPMENT_BECOME_ANY_ACCOUNT");
     cfg.setStringList(
         "site",
         null,
@@ -47,14 +66,29 @@
   }
 
   @Test
-  public void origin() throws Exception {
+  public void missingOriginIsAllowedWithNoCorsResponseHeaders() throws Exception {
     Result change = createChange();
-
     String url = "/changes/" + change.getChangeId() + "/detail";
     RestResponse r = adminRestSession.get(url);
     r.assertOK();
-    assertThat(r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
-    assertThat(r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS)).isNull();
+
+    String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
+    String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
+    String maxAge = r.getHeader(ACCESS_CONTROL_MAX_AGE);
+    String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
+    String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
+
+    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
+    assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
+    assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
+    assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
+    assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
+  }
+
+  @Test
+  public void origins() throws Exception {
+    Result change = createChange();
+    String url = "/changes/" + change.getChangeId() + "/detail";
 
     check(url, true, "http://example.com");
     check(url, true, "https://sub.example.com");
@@ -65,14 +99,26 @@
   }
 
   @Test
-  public void putWithOriginRefused() throws Exception {
+  public void putWithServerOriginAcceptedWithNoCorsResponseHeaders() throws Exception {
+    Result change = createChange();
+    String origin = adminRestSession.url();
+    RestResponse r =
+        adminRestSession.putWithHeader(
+            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
+    r.assertOK();
+    checkCors(r, false, origin);
+    checkTopic(change, "A");
+  }
+
+  @Test
+  public void putWithOtherOriginAccepted() throws Exception {
     Result change = createChange();
     String origin = "http://example.com";
     RestResponse r =
         adminRestSession.putWithHeader(
             "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
     r.assertOK();
-    checkCors(r, false, origin);
+    checkCors(r, true, origin);
   }
 
   @Test
@@ -88,71 +134,158 @@
 
     RestResponse res = adminRestSession.execute(req);
     res.assertOK();
+
+    String vary = res.getHeader(VARY);
+    assertThat(vary).named(VARY).isNotNull();
+    assertThat(Splitter.on(", ").splitToList(vary))
+        .containsExactly(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS);
     checkCors(res, true, origin);
   }
 
   @Test
   public void preflightBadOrigin() throws Exception {
     Result change = createChange();
-
     Request req =
         Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
     req.addHeader(ORIGIN, "http://evil.attacker");
     req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
-
     adminRestSession.execute(req).assertBadRequest();
   }
 
   @Test
   public void preflightBadMethod() throws Exception {
     Result change = createChange();
-
-    for (String method : new String[] {"POST", "PUT", "DELETE", "PATCH"}) {
-      Request req =
-          Request.Options(
-              adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
-      req.addHeader(ORIGIN, "http://example.com");
-      req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, method);
-      adminRestSession.execute(req).assertBadRequest();
-    }
+    Request req =
+        Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
+    req.addHeader(ORIGIN, "http://example.com");
+    req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "CALL");
+    adminRestSession.execute(req).assertBadRequest();
   }
 
   @Test
   public void preflightBadHeader() throws Exception {
     Result change = createChange();
-
     Request req =
         Request.Options(adminRestSession.url() + "/a/changes/" + change.getChangeId() + "/detail");
     req.addHeader(ORIGIN, "http://example.com");
     req.addHeader(ACCESS_CONTROL_REQUEST_METHOD, "GET");
-    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Gerrit-Auth");
-
+    req.addHeader(ACCESS_CONTROL_REQUEST_HEADERS, "X-Secret-Auth-Token");
     adminRestSession.execute(req).assertBadRequest();
   }
 
-  private RestResponse check(String url, boolean accept, String origin) throws Exception {
+  @Test
+  public void crossDomainPutTopic() throws Exception {
+    Result change = createChange();
+    BasicCookieStore cookies = new BasicCookieStore();
+    Executor http = Executor.newInstance().cookieStore(cookies);
+
+    Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id.get());
+    HttpResponse r = http.execute(req).returnResponse();
+    String auth = null;
+    for (Cookie c : cookies.getCookies()) {
+      if ("GerritAccount".equals(c.getName())) {
+        auth = c.getValue();
+      }
+    }
+    assertThat(auth).named("GerritAccount cookie").isNotNull();
+    cookies.clear();
+
+    UrlEncoded url =
+        new UrlEncoded(canonicalWebUrl.get() + "/changes/" + change.getChangeId() + "/topic");
+    url.put("$m", "PUT");
+    url.put("$ct", "application/json; charset=US-ASCII");
+    url.put("access_token", auth);
+
+    String origin = "http://example.com";
+    req = Request.Post(url.toString());
+    req.setHeader(CONTENT_TYPE, "text/plain");
+    req.setHeader(ORIGIN, origin);
+    req.bodyByteArray("{\"topic\":\"test-xd\"}".getBytes(StandardCharsets.US_ASCII));
+
+    r = http.execute(req).returnResponse();
+    assertThat(r.getStatusLine().getStatusCode()).isEqualTo(200);
+
+    Header vary = r.getFirstHeader(VARY);
+    assertThat(vary).named(VARY).isNotNull();
+    assertThat(Splitter.on(", ").splitToList(vary.getValue())).named(VARY).contains(ORIGIN);
+
+    Header allowOrigin = r.getFirstHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
+    assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNotNull();
+    assertThat(allowOrigin.getValue()).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
+
+    Header allowAuth = r.getFirstHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
+    assertThat(allowAuth).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNotNull();
+    assertThat(allowAuth.getValue()).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
+
+    checkTopic(change, "test-xd");
+  }
+
+  @Test
+  public void crossDomainRejectsBadOrigin() throws Exception {
+    Result change = createChange();
+    UrlEncoded url =
+        new UrlEncoded(canonicalWebUrl.get() + "/changes/" + change.getChangeId() + "/topic");
+    url.put("$m", "PUT");
+    url.put("$ct", "application/json; charset=US-ASCII");
+
+    Request req = Request.Post(url.toString());
+    req.setHeader(CONTENT_TYPE, "text/plain");
+    req.setHeader(ORIGIN, "http://evil.attacker");
+    req.bodyByteArray("{\"topic\":\"test-xd\"}".getBytes(StandardCharsets.US_ASCII));
+    adminRestSession.execute(req).assertBadRequest();
+    checkTopic(change, null);
+  }
+
+  private void checkTopic(Result change, @Nullable String topic) throws RestApiException {
+    ChangeInfo info = gApi.changes().id(change.getChangeId()).get();
+    StringSubject t = assertThat(info.topic).named("topic");
+    if (topic != null) {
+      t.isEqualTo(topic);
+    } else {
+      t.isNull();
+    }
+  }
+
+  private void check(String url, boolean accept, String origin) throws Exception {
     Header hdr = new BasicHeader(ORIGIN, origin);
     RestResponse r = adminRestSession.getWithHeader(url, hdr);
     r.assertOK();
     checkCors(r, accept, origin);
-    return r;
   }
 
   private void checkCors(RestResponse r, boolean accept, String origin) {
+    String vary = r.getHeader(VARY);
+    assertThat(vary).named(VARY).isNotNull();
+    assertThat(Splitter.on(", ").splitToList(vary)).named(VARY).contains(ORIGIN);
+
     String allowOrigin = r.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN);
     String allowCred = r.getHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS);
+    String maxAge = r.getHeader(ACCESS_CONTROL_MAX_AGE);
     String allowMethods = r.getHeader(ACCESS_CONTROL_ALLOW_METHODS);
     String allowHeaders = r.getHeader(ACCESS_CONTROL_ALLOW_HEADERS);
     if (accept) {
-      assertThat(allowOrigin).isEqualTo(origin);
-      assertThat(allowCred).isEqualTo("true");
-      assertThat(allowMethods).isEqualTo("GET, OPTIONS");
-      assertThat(allowHeaders).isEqualTo("X-Requested-With");
+      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isEqualTo(origin);
+      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isEqualTo("true");
+      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isEqualTo("600");
+
+      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNotNull();
+      assertThat(Splitter.on(", ").splitToList(allowMethods))
+          .named(ACCESS_CONTROL_ALLOW_METHODS)
+          .containsExactly("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
+
+      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNotNull();
+      assertThat(Splitter.on(", ").splitToList(allowHeaders))
+          .named(ACCESS_CONTROL_ALLOW_HEADERS)
+          .containsExactlyElementsIn(
+              Stream.of(AUTHORIZATION, CONTENT_TYPE, "X-Gerrit-Auth", "X-Requested-With")
+                  .map(s -> s.toLowerCase(Locale.US))
+                  .collect(ImmutableSet.toImmutableSet()));
     } else {
-      assertThat(allowOrigin).isNull();
-      assertThat(allowCred).isNull();
-      assertThat(allowMethods).isNull();
-      assertThat(allowHeaders).isNull();
+      assertThat(allowOrigin).named(ACCESS_CONTROL_ALLOW_ORIGIN).isNull();
+      assertThat(allowCred).named(ACCESS_CONTROL_ALLOW_CREDENTIALS).isNull();
+      assertThat(maxAge).named(ACCESS_CONTROL_MAX_AGE).isNull();
+      assertThat(allowMethods).named(ACCESS_CONTROL_ALLOW_METHODS).isNull();
+      assertThat(allowHeaders).named(ACCESS_CONTROL_ALLOW_HEADERS).isNull();
     }
   }
 }
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 9c68712..3d5932f 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,12 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 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.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
@@ -36,18 +38,18 @@
 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.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+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.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.git.ChangeAlreadyMergedException;
-import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
 import java.util.List;
-import org.eclipse.jgit.lib.Config;
+import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -59,11 +61,6 @@
 import org.junit.Test;
 
 public class CreateChangeIT extends AbstractDaemonTest {
-  @ConfigSuite.Config
-  public static Config allowDraftsDisabled() {
-    return allowDraftsDisabledConfig();
-  }
-
   @BeforeClass
   public static void setTimeForTesting() {
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -100,9 +97,7 @@
     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");
+        ci, ResourceConflictException.class, "invalid Change-Id line format in message footer");
   }
 
   @Test
@@ -112,7 +107,7 @@
     assertCreateFails(
         ci,
         ResourceConflictException.class,
-        "missing subject; Change-Id must be in commit message footer");
+        "missing subject; Change-Id must be in message footer");
   }
 
   @Test
@@ -152,7 +147,7 @@
   @Test
   public void notificationsOnChangeCreation() throws Exception {
     setApiUser(user);
-    watch(project.get(), null);
+    watch(project.get());
 
     // check that watcher is notified
     setApiUser(admin);
@@ -209,16 +204,48 @@
   }
 
   @Test
-  public void createNewDraftChange() throws Exception {
-    assume().that(isAllowDrafts()).isTrue();
-    assertCreateSucceeds(newChangeInput(ChangeStatus.DRAFT));
+  public void createNewPrivateChange() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.isPrivate = true;
+    assertCreateSucceeds(input);
   }
 
   @Test
-  public void createNewDraftChangeNotAllowed() throws Exception {
-    assume().that(isAllowDrafts()).isFalse();
-    ChangeInput ci = newChangeInput(ChangeStatus.DRAFT);
-    assertCreateFails(ci, MethodNotAllowedException.class, "draft workflow is disabled");
+  public void createNewWorkInProgressChange() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.workInProgress = true;
+    assertCreateSucceeds(input);
+  }
+
+  @Test
+  public void createChangeOnNonExistingBaseChangeFails() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.baseChange = "999999";
+    assertCreateFails(
+        input, UnprocessableEntityException.class, "Base change not found: " + input.baseChange);
+  }
+
+  @Test
+  public void createChangeWithoutAccessToParentCommitFails() throws Exception {
+    Map<String, PushOneCommit.Result> results =
+        changeInTwoBranches("invisible-branch", "a.txt", "visible-branch", "b.txt");
+    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
+
+    ChangeInput in = newChangeInput(ChangeStatus.NEW);
+    in.branch = "visible-branch";
+    in.baseChange = results.get("invisible-branch").getChangeId();
+    assertCreateFails(
+        in, UnprocessableEntityException.class, "Base change not found: " + in.baseChange);
+  }
+
+  @Test
+  public void createChangeOnInvisibleBranchFails() throws Exception {
+    changeInTwoBranches("invisible-branch", "a.txt", "branchB", "b.txt");
+    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
+
+    ChangeInput in = newChangeInput(ChangeStatus.NEW);
+    in.branch = "invisible-branch";
+    assertCreateFails(in, ResourceNotFoundException.class, "");
   }
 
   @Test
@@ -369,10 +396,11 @@
     assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
+    assertThat(out.isPrivate).isEqualTo(in.isPrivate);
+    assertThat(out.workInProgress).isEqualTo(in.workInProgress);
     assertThat(out.revisions).hasSize(1);
     assertThat(out.submitted).isNull();
-    Boolean draft = Iterables.getOnlyElement(out.revisions.values()).draft;
-    assertThat(booleanToDraftStatus(draft)).isEqualTo(in.status);
+    assertThat(in.status).isEqualTo(ChangeStatus.NEW);
     return out;
   }
 
@@ -384,13 +412,6 @@
     gApi.changes().create(in);
   }
 
-  private ChangeStatus booleanToDraftStatus(Boolean draft) {
-    if (draft == null) {
-      return ChangeStatus.NEW;
-    }
-    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");
@@ -425,8 +446,18 @@
     return in;
   }
 
-  private void changeInTwoBranches(String branchA, String fileA, String branchB, String fileB)
-      throws Exception {
+  /**
+   * Create an empty commit in master, two new branches with one commit each.
+   *
+   * @param branchA name of first branch to create
+   * @param fileA name of file to commit to branchA
+   * @param branchB name of second branch to create
+   * @param fileB name of file to commit to branchB
+   * @return A {@code Map} of branchName => commit result.
+   * @throws Exception
+   */
+  private Map<String, Result> changeInTwoBranches(
+      String branchA, String fileA, String branchB, String fileB) throws Exception {
     // create a initial commit in master
     Result initialCommit =
         pushFactory
@@ -451,5 +482,7 @@
     commitB.setParent(initialCommit.getCommit());
     Result changeB = commitB.to("refs/heads/" + branchB);
     changeB.assertOkStatus();
+
+    return ImmutableMap.of("master", initialCommit, branchA, changeA, branchB, changeB);
   }
 }
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
deleted file mode 100644
index 38f73c4..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
+++ /dev/null
@@ -1,292 +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.acceptance.rest.change;
-
-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.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.Comment;
-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.inject.Inject;
-import java.util.HashMap;
-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;
-
-@NoHttpd
-public class DeleteDraftPatchSetIT extends AbstractDaemonTest {
-
-  @Inject private AllUsersName allUsers;
-
-  @Test
-  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);
-
-    setApiUser(admin);
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("Patch set is not a draft");
-    deletePatchSet(changeId, ps);
-  }
-
-  @Test
-  public void deleteDraftPatchSetNoACL() 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);
-
-    setApiUser(user);
-    exception.expect(ResourceNotFoundException.class);
-    exception.expectMessage("Not found: " + changeId);
-    deletePatchSet(changeId, ps);
-  }
-
-  @Test
-  public void deleteDraftPatchSetAndChange() throws Exception {
-    String changeId = createDraftChangeWith2PS();
-    PatchSet ps = getCurrentPatchSet(changeId);
-    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);
-    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 (Comment c : cd.publishedComments()) {
-      assertThat(c.key.patchSetId).named(c.toString()).isNotEqualTo(delPsId.get());
-    }
-  }
-
-  @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 (Comment c : cd.publishedComments()) {
-      assertThat(c.key.patchSetId).named(c.toString()).isNotEqualTo(delPsId.get());
-    }
-  }
-
-  @Test
-  public void deleteCurrentDraftPatchSetWhenPreviousPatchSetDoesNotExist() throws Exception {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    String changeId = push.to("refs/for/master").getChangeId();
-    pushFactory
-        .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "foo", changeId)
-        .to("refs/drafts/master");
-    pushFactory
-        .create(db, admin.getIdent(), testRepo, PushOneCommit.SUBJECT, "b.txt", "bar", changeId)
-        .to("refs/drafts/master");
-
-    deletePatchSet(changeId, 2);
-    deletePatchSet(changeId, 3);
-
-    ChangeData cd = getChange(changeId);
-    assertThat(cd.patchSets()).hasSize(1);
-    assertThat(Iterables.getOnlyElement(cd.patchSets()).getId().get()).isEqualTo(1);
-    assertThat(cd.currentPatchSet().getId().get()).isEqualTo(1);
-  }
-
-  @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 {
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
-    Result result = push.to("refs/drafts/master");
-    push =
-        pushFactory.create(
-            db,
-            admin.getIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            "b.txt",
-            "4711",
-            result.getChangeId());
-    return push.to("refs/drafts/master").getChangeId();
-  }
-
-  private PatchSet getCurrentPatchSet(String changeId) throws Exception {
-    return getChange(changeId).currentPatchSet();
-  }
-
-  private ChangeData getChange(String changeId) throws Exception {
-    return Iterables.getOnlyElement(queryProvider.get().byKeyPrefix(changeId));
-  }
-
-  private void deletePatchSet(String changeId, PatchSet ps) throws Exception {
-    deletePatchSet(changeId, ps.getId().get());
-  }
-
-  private void deletePatchSet(String changeId, int ps) throws Exception {
-    gApi.changes().id(changeId).revision(ps).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
deleted file mode 100644
index 3008f39..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
+++ /dev/null
@@ -1,321 +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.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.Iterables;
-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.TimeUtil;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.client.ListChangesOption;
-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.common.RevisionInfo;
-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.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.notedb.PatchSetState;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.testutil.ConfigSuite;
-import com.google.inject.Inject;
-import java.util.Collection;
-import java.util.EnumSet;
-import java.util.List;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Test;
-
-public class DraftChangeIT extends AbstractDaemonTest {
-  @ConfigSuite.Config
-  public static Config allowDraftsDisabled() {
-    return allowDraftsDisabledConfig();
-  }
-
-  @Inject private BatchUpdate.Factory updateFactory;
-
-  @Test
-  public void deleteDraftChange() throws Exception {
-    assume().that(isAllowDrafts()).isTrue();
-    PushOneCommit.Result result = createDraftChange();
-    result.assertOkStatus();
-    String changeId = result.getChangeId();
-    String triplet = project.get() + "~master~" + changeId;
-    ChangeInfo c = get(triplet);
-    assertThat(c.id).isEqualTo(triplet);
-    assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    deleteChange(changeId, adminRestSession).assertNoContent();
-
-    exception.expect(ResourceNotFoundException.class);
-    get(triplet);
-  }
-
-  @Test
-  public void deleteDraftChangeOfAnotherUser() throws Exception {
-    assume().that(isAllowDrafts()).isTrue();
-    PushOneCommit.Result changeResult = createDraftChange();
-    changeResult.assertOkStatus();
-    String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
-
-    // The user needs to be able to see the draft change (which reviewers can).
-    gApi.changes().id(changeId).addReviewer(user.fullName);
-
-    setApiUser(user);
-    exception.expect(AuthException.class);
-    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
-    gApi.changes().id(changeId).delete();
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteDraftChangeWhenDraftsNotAllowedAsNormalUser() throws Exception {
-    assume().that(isAllowDrafts()).isFalse();
-
-    setApiUser(user);
-    // We can't create a draft change while the draft workflow is disabled.
-    // For this reason, we create a normal change and modify the database.
-    PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    Change.Id id = changeResult.getChange().getId();
-    markChangeAsDraft(id);
-    setDraftStatusOfPatchSetsOfChange(id, true);
-
-    String changeId = changeResult.getChangeId();
-    exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("Draft workflow is disabled");
-    gApi.changes().id(changeId).delete();
-  }
-
-  @Test
-  @TestProjectInput(cloneAs = "user")
-  public void deleteDraftChangeWhenDraftsNotAllowedAsAdmin() throws Exception {
-    assume().that(isAllowDrafts()).isFalse();
-
-    setApiUser(user);
-    // We can't create a draft change while the draft workflow is disabled.
-    // For this reason, we create a normal change and modify the database.
-    PushOneCommit.Result changeResult =
-        pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
-    Change.Id id = changeResult.getChange().getId();
-    markChangeAsDraft(id);
-    setDraftStatusOfPatchSetsOfChange(id, true);
-
-    String changeId = changeResult.getChangeId();
-
-    // Grant those permissions to admins.
-    grant(Permission.VIEW_DRAFTS, project, "refs/*");
-    grant(Permission.DELETE_DRAFTS, project, "refs/*");
-
-    try {
-      setApiUser(admin);
-      gApi.changes().id(changeId).delete();
-    } finally {
-      removePermission(Permission.DELETE_DRAFTS, project, "refs/*");
-      removePermission(Permission.VIEW_DRAFTS, project, "refs/*");
-    }
-
-    setApiUser(user);
-    assertThat(query(changeId)).isEmpty();
-  }
-
-  @Test
-  public void deleteDraftChangeWithNonDraftPatchSet() throws Exception {
-    assume().that(isAllowDrafts()).isTrue();
-
-    PushOneCommit.Result changeResult = createDraftChange();
-    Change.Id id = changeResult.getChange().getId();
-    setDraftStatusOfPatchSetsOfChange(id, false);
-
-    String changeId = changeResult.getChangeId();
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Cannot delete draft change %s: patch set 1 is not a draft", id));
-    gApi.changes().id(changeId).delete();
-  }
-
-  @Test
-  public void publishDraftChange() throws Exception {
-    assume().that(isAllowDrafts()).isTrue();
-    PushOneCommit.Result result = createDraftChange();
-    result.assertOkStatus();
-    String changeId = result.getChangeId();
-    String triplet = project.get() + "~master~" + changeId;
-    ChangeInfo c = get(triplet);
-    assertThat(c.id).isEqualTo(triplet);
-    assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    assertThat(c.revisions.get(c.currentRevision).draft).isTrue();
-    publishChange(changeId).assertNoContent();
-    c = get(triplet);
-    assertThat(c.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(c.revisions.get(c.currentRevision).draft).isNull();
-  }
-
-  @Test
-  public void publishDraftPatchSet() throws Exception {
-    assume().that(isAllowDrafts()).isTrue();
-    PushOneCommit.Result result = createDraftChange();
-    result.assertOkStatus();
-    String changeId = result.getChangeId();
-    String triplet = project.get() + "~master~" + changeId;
-    ChangeInfo c = get(triplet);
-    assertThat(c.id).isEqualTo(triplet);
-    assertThat(c.status).isEqualTo(ChangeStatus.DRAFT);
-    publishPatchSet(changeId).assertNoContent();
-    assertThat(get(triplet).status).isEqualTo(ChangeStatus.NEW);
-  }
-
-  @Test
-  public void createDraftChangeWhenDraftsNotAllowed() throws Exception {
-    assume().that(isAllowDrafts()).isFalse();
-    PushOneCommit.Result r = createDraftChange();
-    r.assertErrorStatus("draft workflow is disabled");
-  }
-
-  @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);
-
-    Collection<AccountInfo> ccs = info.reviewers.get(ReviewerState.REVIEWER);
-    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 Exception {
-    return s.delete("/changes/" + changeId);
-  }
-
-  private RestResponse publishChange(String changeId) throws Exception {
-    return adminRestSession.post("/changes/" + changeId + "/publish");
-  }
-
-  private RestResponse publishPatchSet(String changeId) throws Exception {
-    PatchSet patchSet =
-        Iterables.getOnlyElement(queryProvider.get().byKeyPrefix(changeId)).currentPatchSet();
-    return adminRestSession.post(
-        "/changes/" + changeId + "/revisions/" + patchSet.getRevision().get() + "/publish");
-  }
-
-  private void markChangeAsDraft(Change.Id id) throws Exception {
-    try (BatchUpdate batchUpdate =
-        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
-      batchUpdate.addOp(id, new MarkChangeAsDraftUpdateOp());
-      batchUpdate.execute();
-    }
-
-    ChangeStatus changeStatus = gApi.changes().id(id.get()).get().status;
-    assertThat(changeStatus).isEqualTo(ChangeStatus.DRAFT);
-  }
-
-  private void setDraftStatusOfPatchSetsOfChange(Change.Id id, boolean draftStatus)
-      throws Exception {
-    try (BatchUpdate batchUpdate =
-        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
-      batchUpdate.addOp(id, new DraftStatusOfPatchSetsUpdateOp(draftStatus));
-      batchUpdate.execute();
-    }
-
-    Boolean expectedDraftStatus = draftStatus ? Boolean.TRUE : null;
-    List<Boolean> patchSetDraftStatuses = getPatchSetDraftStatuses(id);
-    patchSetDraftStatuses.forEach(status -> assertThat(status).isEqualTo(expectedDraftStatus));
-  }
-
-  private List<Boolean> getPatchSetDraftStatuses(Change.Id id) throws Exception {
-    Collection<RevisionInfo> revisionInfos =
-        gApi.changes()
-            .id(id.get())
-            .get(EnumSet.of(ListChangesOption.ALL_REVISIONS))
-            .revisions
-            .values();
-    return revisionInfos.stream().map(revisionInfo -> revisionInfo.draft).collect(toList());
-  }
-
-  private static class MarkChangeAsDraftUpdateOp implements BatchUpdateOp {
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws Exception {
-      Change change = ctx.getChange();
-
-      // Change status in database.
-      change.setStatus(Change.Status.DRAFT);
-
-      // Change status in NoteDb.
-      PatchSet.Id currentPatchSetId = change.currentPatchSetId();
-      ctx.getUpdate(currentPatchSetId).setStatus(Change.Status.DRAFT);
-
-      return true;
-    }
-  }
-
-  private class DraftStatusOfPatchSetsUpdateOp implements BatchUpdateOp {
-    private final boolean draftStatus;
-
-    DraftStatusOfPatchSetsUpdateOp(boolean draftStatus) {
-      this.draftStatus = draftStatus;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws Exception {
-      Collection<PatchSet> patchSets = psUtil.byChange(db, ctx.getNotes());
-
-      // Change status in database.
-      patchSets.forEach(patchSet -> patchSet.setDraft(draftStatus));
-      db.patchSets().update(patchSets);
-
-      // Change status in NoteDb.
-      PatchSetState patchSetState = draftStatus ? PatchSetState.DRAFT : PatchSetState.PUBLISHED;
-      patchSets.stream()
-          .map(PatchSet::getId)
-          .map(ctx::getUpdate)
-          .forEach(changeUpdate -> changeUpdate.setPatchSetState(patchSetState));
-
-      return true;
-    }
-  }
-}
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 18925b4..08f0699 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
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.testutil.TestTimeUtil;
 import org.junit.AfterClass;
 import org.junit.Before;
@@ -77,6 +78,15 @@
   }
 
   @Test
+  public void addInvalidHashtag() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("hashtags may not contain commas");
+    addHashtags(r, "invalid,hashtag");
+  }
+
+  @Test
   public void addMultipleHashtags() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -252,14 +262,14 @@
     PushOneCommit.Result r = createChange();
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("Editing hashtags not permitted");
+    exception.expectMessage("edit hashtags not permitted");
     addHashtags(r, "MyHashtag");
   }
 
   @Test
   public void addHashtagWithPermissionAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
-    grant(Permission.EDIT_HASHTAGS, project, "refs/heads/master", false, REGISTERED_USERS);
+    grant(project, "refs/heads/master", Permission.EDIT_HASHTAGS, false, REGISTERED_USERS);
     setApiUser(user);
     addHashtags(r, "MyHashtag");
     assertThatGet(r).containsExactly("MyHashtag");
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 09a0a3e..94138cf 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
@@ -48,7 +48,7 @@
   @Test
   public void indexChangeAfterOwnerLosesVisibility() throws Exception {
     // Create a test group with 2 users as members
-    TestAccount user2 = accounts.user2();
+    TestAccount user2 = accountCreator.user2();
     String group = createGroup("test");
     gApi.groups().id(group).addMembers("admin", "user", user2.username);
 
@@ -58,7 +58,7 @@
     Util.allow(
         cfg,
         Permission.READ,
-        groupCache.get(new AccountGroup.NameKey(group)).getGroupUUID(),
+        groupCache.get(new AccountGroup.NameKey(group)).get().getGroupUUID(),
         "refs/*");
     Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     saveProjectConfig(p, cfg);
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
index 36f8452..8096bbd 100644
--- 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
@@ -22,18 +22,22 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.data.LabelFunction;
 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.BadRequestException;
 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 java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -159,11 +163,11 @@
         new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
     createBranch(newBranch);
     block(
+        "refs/for/" + newBranch.get(),
         Permission.PUSH,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
-        "refs/for/" + newBranch.get());
+        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
     exception.expect(AuthException.class);
-    exception.expectMessage("Move not permitted");
+    exception.expectMessage("move not permitted");
     move(r.getChangeId(), newBranch.get());
   }
 
@@ -174,12 +178,12 @@
     Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     block(
+        r.getChange().change().getDest().get(),
         Permission.ABANDON,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
-        r.getChange().change().getDest().get());
+        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("Move not permitted");
+    exception.expectMessage("move not permitted");
     move(r.getChangeId(), newBranch.get());
   }
 
@@ -219,14 +223,103 @@
     Util.allow(
         cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, registeredUsers, "refs/heads/*");
     saveProjectConfig(cfg);
-    grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
+    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
 
     exception.expect(AuthException.class);
-    exception.expectMessage("Move not permitted");
+    exception.expectMessage("move not permitted");
     move(r.getChangeId(), newBranch.get());
   }
 
+  @Test
+  public void moveChangeOnlyKeepVetoVotes() throws Exception {
+    // A vote for a label will be kept after moving if the label's function is *WithBlock and the
+    // vote holds the minimum value.
+    createBranch(new Branch.NameKey(project, "foo"));
+
+    String codeReviewLabel = "Code-Review"; // 'Code-Review' uses 'MaxWithBlock' function.
+    String testLabelA = "Label-A";
+    String testLabelB = "Label-B";
+    String testLabelC = "Label-C";
+    configLabel(testLabelA, LabelFunction.ANY_WITH_BLOCK);
+    configLabel(testLabelB, LabelFunction.MAX_NO_BLOCK);
+    configLabel(testLabelC, LabelFunction.NO_BLOCK);
+
+    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(cfg, Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(testLabelB), -1, +1, registered, "refs/heads/*");
+    Util.allow(cfg, Permission.forLabel(testLabelC), -1, +1, registered, "refs/heads/*");
+    saveProjectConfig(cfg);
+
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).current().review(ReviewInput.reject());
+
+    amendChange(changeId);
+
+    ReviewInput input = new ReviewInput();
+    input.label(testLabelA, -1);
+    input.label(testLabelB, -1);
+    input.label(testLabelC, -1);
+    gApi.changes().id(changeId).current().review(input);
+
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().keySet())
+        .containsExactly(codeReviewLabel, testLabelA, testLabelB, testLabelC);
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().values())
+        .containsExactly((short) -2, (short) -1, (short) -1, (short) -1);
+
+    // Move the change to the 'foo' branch.
+    assertThat(gApi.changes().id(changeId).get().branch).isEqualTo("master");
+    move(changeId, "foo");
+    assertThat(gApi.changes().id(changeId).get().branch).isEqualTo("foo");
+
+    // 'Code-Review -2' and 'Label-A -1' will be kept.
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().values())
+        .containsExactly((short) -2, (short) -1, (short) 0, (short) 0);
+
+    // Move the change back to 'master'.
+    move(changeId, "master");
+    assertThat(gApi.changes().id(changeId).get().branch).isEqualTo("master");
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().values())
+        .containsExactly((short) -2, (short) -1, (short) 0, (short) 0);
+  }
+
+  @Test
+  public void moveToBranchWithoutLabel() throws Exception {
+    createBranch(new Branch.NameKey(project, "foo"));
+    String testLabelA = "Label-A";
+    configLabel(testLabelA, LabelFunction.MAX_WITH_BLOCK, Arrays.asList("refs/heads/master"));
+
+    AccountGroup.UUID registered = SystemGroupBackend.REGISTERED_USERS;
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.allow(cfg, Permission.forLabel(testLabelA), -1, +1, registered, "refs/heads/master");
+    saveProjectConfig(cfg);
+
+    String changeId = createChange().getChangeId();
+
+    ReviewInput input = new ReviewInput();
+    input.label(testLabelA, -1);
+    gApi.changes().id(changeId).current().review(input);
+
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().keySet())
+        .containsExactly(testLabelA);
+    assertThat(gApi.changes().id(changeId).current().reviewer(admin.email).votes().values())
+        .containsExactly((short) -1);
+
+    move(changeId, "foo");
+
+    // TODO(dpursehouse): Assert about state of labels after move
+  }
+
+  @Test
+  public void moveNoDestinationBranchSpecified() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("destination branch is required");
+    move(r.getChangeId(), null);
+  }
+
   private void move(int changeNum, String destination) throws RestApiException {
     gApi.changes().id(changeNum).move(destination);
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
new file mode 100644
index 0000000..993c144
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/PrivateByDefaultIT.java
@@ -0,0 +1,159 @@
+// 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.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.reviewdb.client.Project;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PrivateByDefaultIT extends AbstractDaemonTest {
+  private Project.NameKey project1;
+  private Project.NameKey project2;
+
+  @Before
+  public void setUp() throws Exception {
+    project1 = createProject("project-1");
+    project2 = createProject("project-2", project1);
+    setPrivateByDefault(project1, InheritableBoolean.FALSE);
+  }
+
+  @Test
+  public void createChangeWithPrivateByDefaultEnabled() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().isPrivate).isEqualTo(true);
+  }
+
+  @Test
+  public void createChangeBypassPrivateByDefaultEnabled() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    input.isPrivate = false;
+    assertThat(gApi.changes().create(input).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void createChangeWithPrivateByDefaultDisabled() throws Exception {
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+    assertThat(info.isPrivate).isNull();
+  }
+
+  @Test
+  public void createChangeWithPrivateByDefaultInherited() throws Exception {
+    setPrivateByDefault(project1, InheritableBoolean.TRUE);
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+    assertThat(info.isPrivate).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void createChangeWithPrivateByDefaultAndDisablePrivateChangesTrue() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("private changes are disabled");
+    gApi.changes().create(input);
+  }
+
+  @Test
+  public void pushWithPrivateByDefaultEnabled() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+    assertThat(createChange(project2).getChange().change().isPrivate()).isEqualTo(true);
+  }
+
+  @Test
+  public void pushBypassPrivateByDefaultEnabled() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+    assertThat(
+            createChange(project2, "refs/for/master%remove-private")
+                .getChange()
+                .change()
+                .isPrivate())
+        .isEqualTo(false);
+  }
+
+  @Test
+  public void pushWithPrivateByDefaultDisabled() throws Exception {
+    assertThat(createChange(project2).getChange().change().isPrivate()).isEqualTo(false);
+  }
+
+  @Test
+  public void pushBypassPrivateByDefaultInherited() throws Exception {
+    setPrivateByDefault(project1, InheritableBoolean.TRUE);
+    assertThat(createChange(project2).getChange().change().isPrivate()).isEqualTo(true);
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushPrivatesWithPrivateByDefaultAndDisablePrivateChangesTrue() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project2);
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%private");
+    result.assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
+  public void pushDraftsWithPrivateByDefaultAndDisablePrivateChangesTrue() throws Exception {
+    setPrivateByDefault(project2, InheritableBoolean.TRUE);
+
+    RevCommit initialHead = getRemoteHead();
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project2);
+    PushOneCommit.Result result =
+        pushFactory.create(db, admin.getIdent(), testRepo).to("refs/for/master%draft");
+    result.assertErrorStatus();
+
+    testRepo.reset(initialHead);
+    result = pushFactory.create(db, admin.getIdent(), testRepo).to("refs/drafts/master");
+    result.assertErrorStatus();
+  }
+
+  private void setPrivateByDefault(Project.NameKey proj, InheritableBoolean value)
+      throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.privateByDefault = value;
+    gApi.projects().name(proj.get()).config(input);
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey proj) throws Exception {
+    return createChange(proj, "refs/for/master");
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey proj, String ref) throws Exception {
+    TestRepository<InMemoryRepository> testRepo = cloneProject(proj);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to(ref);
+    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 26a91aa..a385932 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
@@ -15,6 +15,7 @@
 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;
@@ -388,6 +389,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -396,10 +400,11 @@
     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);
+    TestSubmitInput failInput = new TestSubmitInput();
+    failInput.failAfterRefUpdates = true;
     submit(
         change2.getChangeId(),
-        failAfterRefUpdates,
+        failInput,
         ResourceConflictException.class,
         "Failing after ref updates");
     RevCommit headAfterFailedSubmit = getRemoteHead();
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 65ad499..d4397d64 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,13 +15,13 @@
 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.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;
@@ -145,12 +145,16 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     Change.Id id = change.getChange().getId();
-    SubmitInput failAfterRefUpdates = new TestSubmitInput(new SubmitInput(), true);
+    TestSubmitInput failInput = new TestSubmitInput();
+    failInput.failAfterRefUpdates = true;
     submit(
         change.getChangeId(),
-        failAfterRefUpdates,
+        failInput,
         ResourceConflictException.class,
         "Failing after ref updates");
 
@@ -189,8 +193,8 @@
   public void submitSameCommitsAsInExperimentalBranch() throws Exception {
     RevCommit initialHead = getRemoteHead();
 
-    grant(Permission.CREATE, project, "refs/heads/*");
-    grant(Permission.PUSH, project, "refs/heads/experimental");
+    grant(project, "refs/heads/*", Permission.CREATE);
+    grant(project, "refs/heads/experimental", Permission.PUSH);
 
     RevCommit c1 = commitBuilder().add("b.txt", "1").message("commit at tip").create();
     String id1 = GitUtil.getChangeId(testRepo, c1).get();
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 0ac263f..bb4abe1 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
@@ -19,7 +19,6 @@
 
 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;
@@ -510,30 +509,6 @@
   }
 
   @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();
-  }
-
-  @Test
   public void testPreviewSubmitTgz() throws Exception {
     Project.NameKey p1 = createProject("project-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 308c9a5..bb7da11 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
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.git.ChangeSet;
 import com.google.gerrit.server.git.MergeSuperSet;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gwtorm.server.OrmException;
@@ -304,7 +305,8 @@
   }
 
   private void assertChangeSetMergeable(ChangeData change, boolean expected)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException, OrmException {
+      throws MissingObjectException, IncorrectObjectTypeException, IOException, OrmException,
+          PermissionBackendException {
     ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, change.change(), user(admin));
     assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
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 6534810..c5c864d 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
@@ -25,19 +25,16 @@
 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;
-import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 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.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.group.CreateGroup;
-import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import java.util.List;
@@ -48,11 +45,9 @@
 public class SuggestReviewersIT extends AbstractDaemonTest {
   @Inject private CreateGroup.Factory createGroupFactory;
 
-  @Inject private GroupsCollection groups;
-
-  private AccountGroup group1;
-  private AccountGroup group2;
-  private AccountGroup group3;
+  private InternalGroup group1;
+  private InternalGroup group2;
+  private InternalGroup group3;
 
   private TestAccount user1;
   private TestAccount user2;
@@ -144,8 +139,8 @@
     List<SuggestedReviewerInfo> reviewers;
 
     setApiUser(user3);
-    block("read", ANONYMOUS_USERS, "refs/*");
-    allow("read", group1.getGroupUUID(), "refs/*");
+    block("refs/*", "read", ANONYMOUS_USERS);
+    allow("refs/*", "read", group1.getGroupUUID());
     reviewers = suggestReviewers(changeId, user2.username, 2);
     assertThat(reviewers).isEmpty();
   }
@@ -240,8 +235,8 @@
   @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");
+    InternalGroup largeGroup = group("large");
+    InternalGroup 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
@@ -402,6 +397,24 @@
         .inOrder();
   }
 
+  @Test
+  public void suggestNoInactiveAccounts() throws Exception {
+    String name = name("foo");
+    TestAccount foo1 = accountCreator.create(name + "-1");
+    assertThat(gApi.accounts().id(foo1.username).getActive()).isTrue();
+
+    TestAccount foo2 = accountCreator.create(name + "-2");
+    assertThat(gApi.accounts().id(foo2.username).getActive()).isTrue();
+
+    String changeId = createChange().getChangeId();
+    assertReviewers(
+        suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
+
+    gApi.accounts().id(foo2.username).setActive(false);
+    assertThat(gApi.accounts().id(foo2.username).getActive()).isFalse();
+    assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
+  }
+
   private List<SuggestedReviewerInfo> suggestReviewers(String changeId, String query)
       throws Exception {
     return gApi.changes().id(changeId).suggestReviewers(query).get();
@@ -412,19 +425,19 @@
     return gApi.changes().id(changeId).suggestReviewers(query).withLimit(n).get();
   }
 
-  private AccountGroup group(String name) throws Exception {
+  private InternalGroup group(String name) throws Exception {
     GroupInfo group = createGroupFactory.create(name(name)).apply(TopLevelResource.INSTANCE, null);
-    GroupDescription.Basic d = groups.parseInternal(Url.decode(group.id));
-    return GroupDescriptions.toAccountGroup(d);
+    return groupCache.get(new AccountGroup.UUID(group.id)).orElse(null);
   }
 
-  private TestAccount user(String name, String fullName, String emailName, AccountGroup... groups)
+  private TestAccount user(String name, String fullName, String emailName, InternalGroup... groups)
       throws Exception {
-    String[] groupNames = Arrays.stream(groups).map(AccountGroup::getName).toArray(String[]::new);
-    return accounts.create(name(name), name(emailName) + "@example.com", fullName, groupNames);
+    String[] groupNames = Arrays.stream(groups).map(InternalGroup::getName).toArray(String[]::new);
+    return accountCreator.create(
+        name(name), name(emailName) + "@example.com", fullName, groupNames);
   }
 
-  private TestAccount user(String name, String fullName, AccountGroup... groups) throws Exception {
+  private TestAccount user(String name, String fullName, InternalGroup... groups) throws Exception {
     return user(name, fullName, name, groups);
   }
 
@@ -449,7 +462,7 @@
   private void assertReviewers(
       List<SuggestedReviewerInfo> actual,
       List<TestAccount> expectedUsers,
-      List<AccountGroup> expectedGroups) {
+      List<InternalGroup> expectedGroups) {
     List<Integer> actualAccountIds =
         actual.stream()
             .filter(i -> i.account != null)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java
new file mode 100644
index 0000000..34d87d0
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/WorkInProgressByDefaultIT.java
@@ -0,0 +1,158 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.reviewdb.client.Project;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class WorkInProgressByDefaultIT extends AbstractDaemonTest {
+  private Project.NameKey project1;
+  private Project.NameKey project2;
+
+  @Before
+  public void setUp() throws Exception {
+    project1 = createProject("project-1");
+    project2 = createProject("project-2", project1);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    setApiUser(admin);
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.workInProgressByDefault = false;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForProjectDisabled() throws Exception {
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+    assertThat(info.workInProgress).isNull();
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    setWorkInProgressByDefaultForProject(project2);
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    setWorkInProgressByDefaultForUser();
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    assertThat(gApi.changes().create(input).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void createChangeBypassWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    setWorkInProgressByDefaultForProject(project2);
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    input.workInProgress = false;
+    assertThat(gApi.changes().create(input).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void createChangeBypassWorkInProgressByDefaultForUserEnabled() throws Exception {
+    setWorkInProgressByDefaultForUser();
+    ChangeInput input = new ChangeInput(project2.get(), "master", "empty change");
+    input.workInProgress = false;
+    assertThat(gApi.changes().create(input).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void createChangeWithWorkInProgressByDefaultForProjectInherited() throws Exception {
+    setWorkInProgressByDefaultForProject(project1);
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project2.get(), "master", "empty change")).get();
+    assertThat(info.workInProgress).isTrue();
+  }
+
+  @Test
+  public void pushWithWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    setWorkInProgressByDefaultForProject(project2);
+    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  @Test
+  public void pushWithWorkInProgressByDefaultForUserEnabled() throws Exception {
+    setWorkInProgressByDefaultForUser();
+    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  @Test
+  public void pushBypassWorkInProgressByDefaultForProjectEnabled() throws Exception {
+    setWorkInProgressByDefaultForProject(project2);
+    assertThat(
+            createChange(project2, "refs/for/master%ready").getChange().change().isWorkInProgress())
+        .isFalse();
+  }
+
+  @Test
+  public void pushBypassWorkInProgressByDefaultForUserEnabled() throws Exception {
+    setWorkInProgressByDefaultForUser();
+    assertThat(
+            createChange(project2, "refs/for/master%ready").getChange().change().isWorkInProgress())
+        .isFalse();
+  }
+
+  @Test
+  public void pushWithWorkInProgressByDefaultForProjectDisabled() throws Exception {
+    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isFalse();
+  }
+
+  @Test
+  public void pushWorkInProgressByDefaultForProjectInherited() throws Exception {
+    setWorkInProgressByDefaultForProject(project1);
+    assertThat(createChange(project2).getChange().change().isWorkInProgress()).isTrue();
+  }
+
+  private void setWorkInProgressByDefaultForProject(Project.NameKey p) throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.workInProgressByDefault = InheritableBoolean.TRUE;
+    gApi.projects().name(p.get()).config(input);
+  }
+
+  private void setWorkInProgressByDefaultForUser() throws Exception {
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.workInProgressByDefault = true;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey p) throws Exception {
+    return createChange(p, "refs/for/master");
+  }
+
+  private PushOneCommit.Result createChange(Project.NameKey p, String r) throws Exception {
+    TestRepository<InMemoryRepository> testRepo = cloneProject(p);
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
+    PushOneCommit.Result result = push.to(r);
+    result.assertOkStatus();
+    return result;
+  }
+}
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 3f675ef..b586ab2 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
@@ -15,27 +15,35 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.group.InternalGroup;
 import org.junit.Test;
 
 public class FlushCacheIT extends AbstractDaemonTest {
 
   @Test
   public void flushCache() throws Exception {
-    RestResponse r = adminRestSession.get("/config/server/caches/groups");
+    InternalGroup group = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
+    assertWithMessage("Precondition: The group 'Administrators' was loaded by the group cache")
+        .that(group)
+        .isNotNull();
+
+    RestResponse r = adminRestSession.get("/config/server/caches/groups_byname");
     CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(result.entries.mem).isGreaterThan((long) 0);
 
-    r = adminRestSession.post("/config/server/caches/groups/flush");
+    r = adminRestSession.post("/config/server/caches/groups_byname/flush");
     r.assertOK();
     r.consume();
 
-    r = adminRestSession.get("/config/server/caches/groups");
+    r = adminRestSession.get("/config/server/caches/groups_byname");
     result = newGson().fromJson(r.getReader(), CacheInfo.class);
     assertThat(result.entries.mem).isNull();
   }
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 2c907e5..86f375e 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -39,7 +40,7 @@
             .filter(t -> "Log File Compressor".equals(t.command))
             .map(t -> t.id)
             .findFirst();
-    assertThat(id.isPresent()).isTrue();
+    assertThat(id).isPresent();
 
     r = adminRestSession.delete("/config/server/tasks/" + id.get());
     r.assertNoContent();
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 55ca719..22f1602 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
@@ -20,24 +20,26 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import org.junit.Test;
 
 @NoHttpd
 public class ServerInfoIT extends AbstractDaemonTest {
-  @Inject private SitePaths sitePaths;
+  private static final byte[] JS_PLUGIN_CONTENT =
+      "Gerrit.install(function(self){});\n".getBytes(UTF_8);
 
   @Test
+  // accounts
+  @GerritConfig(name = "accounts.visibility", value = "VISIBLE_GROUP")
+
   // auth
   @GerritConfig(name = "auth.type", value = "HTTP")
   @GerritConfig(name = "auth.contributorAgreements", value = "true")
@@ -52,11 +54,11 @@
   @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
 
   // change
-  @GerritConfig(name = "change.allowDrafts", value = "false")
   @GerritConfig(name = "change.largeChange", value = "300")
   @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
   @GerritConfig(name = "change.replyLabel", value = "Vote")
   @GerritConfig(name = "change.updateDelay", value = "50s")
+  @GerritConfig(name = "change.disablePrivateChanges", value = "true")
 
   // download
   @GerritConfig(
@@ -78,6 +80,9 @@
   public void serverConfig() throws Exception {
     ServerInfo i = gApi.config().server().getInfo();
 
+    // accounts
+    assertThat(i.accounts.visibility).isEqualTo(AccountVisibility.VISIBLE_GROUP);
+
     // auth
     assertThat(i.auth.authType).isEqualTo(AuthType.HTTP);
     assertThat(i.auth.editableAccountFields)
@@ -92,11 +97,11 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.allowDrafts).isNull();
     assertThat(i.change.largeChange).isEqualTo(300);
     assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
     assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
     assertThat(i.change.updateDelay).isEqualTo(50);
+    assertThat(i.change.disablePrivateChanges).isTrue();
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
@@ -131,18 +136,16 @@
   }
 
   @Test
-  @UseSsh
   @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
   public void serverConfigWithPlugin() throws Exception {
-    Path plugins = sitePaths.plugins_dir;
-    Files.createDirectory(plugins);
-    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 = gApi.config().server().getInfo();
+    assertThat(i.plugin.jsResourcePaths).isEmpty();
 
-    // plugin
+    InstallPluginInput input = new InstallPluginInput();
+    input.raw = RawInputUtil.create(JS_PLUGIN_CONTENT);
+    gApi.plugins().install("js-plugin-1.js", input);
+
+    i = gApi.config().server().getInfo();
     assertThat(i.plugin.jsResourcePaths).hasSize(1);
   }
 
@@ -167,11 +170,10 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.allowDrafts).isTrue();
     assertThat(i.change.largeChange).isEqualTo(500);
     assertThat(i.change.replyTooltip).startsWith("Reply and score");
     assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
-    assertThat(i.change.updateDelay).isEqualTo(30);
+    assertThat(i.change.updateDelay).isEqualTo(300);
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
new file mode 100644
index 0000000..cea907d
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -0,0 +1,261 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.GitUtil.createAnnotatedTag;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.GitUtil.updateAnnotatedTag;
+import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.ANNOTATED;
+import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.LIGHTWEIGHT;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.base.MoreObjects;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.client.RefNames;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public abstract class AbstractPushTag extends AbstractDaemonTest {
+  enum TagType {
+    LIGHTWEIGHT(Permission.CREATE),
+    ANNOTATED(Permission.CREATE_TAG);
+
+    final String createPermission;
+
+    TagType(String createPermission) {
+      this.createPermission = createPermission;
+    }
+  }
+
+  private RevCommit initialHead;
+  private TagType tagType;
+
+  @Before
+  public void setup() throws Exception {
+    // clone with user to avoid inherited tag permissions of admin user
+    testRepo = cloneProject(project, user);
+
+    initialHead = getRemoteHead();
+    tagType = getTagType();
+  }
+
+  protected abstract TagType getTagType();
+
+  @Test
+  public void createTagForExistingCommit() throws Exception {
+    pushTagForExistingCommit(Status.REJECTED_OTHER_REASON);
+
+    allowTagCreation();
+    pushTagForExistingCommit(Status.OK);
+
+    allowPushOnRefsTags();
+    pushTagForExistingCommit(Status.OK);
+
+    removePushFromRefsTags();
+  }
+
+  @Test
+  public void createTagForNewCommit() throws Exception {
+    pushTagForNewCommit(Status.REJECTED_OTHER_REASON);
+
+    allowTagCreation();
+    pushTagForNewCommit(Status.REJECTED_OTHER_REASON);
+
+    allowPushOnRefsTags();
+    pushTagForNewCommit(Status.OK);
+
+    removePushFromRefsTags();
+  }
+
+  @Test
+  public void fastForward() throws Exception {
+    allowTagCreation();
+    String tagName = pushTagForExistingCommit(Status.OK);
+
+    fastForwardTagToExistingCommit(tagName, Status.REJECTED_OTHER_REASON);
+    fastForwardTagToNewCommit(tagName, Status.REJECTED_OTHER_REASON);
+
+    allowTagDeletion();
+    fastForwardTagToExistingCommit(tagName, Status.REJECTED_OTHER_REASON);
+    fastForwardTagToNewCommit(tagName, Status.REJECTED_OTHER_REASON);
+
+    allowPushOnRefsTags();
+    Status expectedStatus = tagType == ANNOTATED ? Status.REJECTED_OTHER_REASON : Status.OK;
+    fastForwardTagToExistingCommit(tagName, expectedStatus);
+    fastForwardTagToNewCommit(tagName, expectedStatus);
+
+    allowForcePushOnRefsTags();
+    fastForwardTagToExistingCommit(tagName, Status.OK);
+    fastForwardTagToNewCommit(tagName, Status.OK);
+
+    removePushFromRefsTags();
+  }
+
+  @Test
+  public void forceUpdate() throws Exception {
+    allowTagCreation();
+    String tagName = pushTagForExistingCommit(Status.OK);
+
+    forceUpdateTagToExistingCommit(tagName, Status.REJECTED_OTHER_REASON);
+    forceUpdateTagToNewCommit(tagName, Status.REJECTED_OTHER_REASON);
+
+    allowPushOnRefsTags();
+    forceUpdateTagToExistingCommit(tagName, Status.REJECTED_OTHER_REASON);
+    forceUpdateTagToNewCommit(tagName, Status.REJECTED_OTHER_REASON);
+
+    allowTagDeletion();
+    forceUpdateTagToExistingCommit(tagName, Status.REJECTED_OTHER_REASON);
+    forceUpdateTagToNewCommit(tagName, Status.REJECTED_OTHER_REASON);
+
+    allowForcePushOnRefsTags();
+    forceUpdateTagToExistingCommit(tagName, Status.OK);
+    forceUpdateTagToNewCommit(tagName, Status.OK);
+
+    removePushFromRefsTags();
+  }
+
+  @Test
+  public void delete() throws Exception {
+    allowTagCreation();
+    String tagName = pushTagForExistingCommit(Status.OK);
+
+    pushTagDeletion(tagName, Status.REJECTED_OTHER_REASON);
+
+    allowPushOnRefsTags();
+    pushTagDeletion(tagName, Status.REJECTED_OTHER_REASON);
+
+    allowForcePushOnRefsTags();
+    tagName = pushTagForExistingCommit(Status.OK);
+    pushTagDeletion(tagName, Status.OK);
+
+    removePushFromRefsTags();
+    allowTagDeletion();
+    tagName = pushTagForExistingCommit(Status.OK);
+    pushTagDeletion(tagName, Status.OK);
+  }
+
+  private String pushTagForExistingCommit(Status expectedStatus) throws Exception {
+    return pushTag(null, false, false, expectedStatus);
+  }
+
+  private String pushTagForNewCommit(Status expectedStatus) throws Exception {
+    return pushTag(null, true, false, expectedStatus);
+  }
+
+  private void fastForwardTagToExistingCommit(String tagName, Status expectedStatus)
+      throws Exception {
+    pushTag(tagName, false, false, expectedStatus);
+  }
+
+  private void fastForwardTagToNewCommit(String tagName, Status expectedStatus) throws Exception {
+    pushTag(tagName, true, false, expectedStatus);
+  }
+
+  private void forceUpdateTagToExistingCommit(String tagName, Status expectedStatus)
+      throws Exception {
+    pushTag(tagName, false, true, expectedStatus);
+  }
+
+  private void forceUpdateTagToNewCommit(String tagName, Status expectedStatus) throws Exception {
+    pushTag(tagName, true, true, expectedStatus);
+  }
+
+  private String pushTag(String tagName, boolean newCommit, boolean force, Status expectedStatus)
+      throws Exception {
+    if (force) {
+      testRepo.reset(initialHead);
+    }
+    commit(user.getIdent(), "subject");
+
+    boolean createTag = tagName == null;
+    tagName = MoreObjects.firstNonNull(tagName, "v1_" + System.nanoTime());
+    switch (tagType) {
+      case LIGHTWEIGHT:
+        break;
+      case ANNOTATED:
+        if (createTag) {
+          createAnnotatedTag(testRepo, tagName, user.getIdent());
+        } else {
+          updateAnnotatedTag(testRepo, tagName, user.getIdent());
+        }
+        break;
+      default:
+        throw new IllegalStateException("unexpected tag type: " + tagType);
+    }
+
+    if (!newCommit) {
+      grant(project, "refs/for/refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+      pushHead(testRepo, "refs/for/master%submit");
+    }
+
+    String tagRef = tagRef(tagName);
+    PushResult r =
+        tagType == LIGHTWEIGHT
+            ? pushHead(testRepo, tagRef, false, force)
+            : GitUtil.pushTag(testRepo, tagName, !createTag);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
+    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+    return tagName;
+  }
+
+  private void pushTagDeletion(String tagName, Status expectedStatus) throws Exception {
+    String tagRef = tagRef(tagName);
+    PushResult r = deleteRef(testRepo, tagRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
+    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
+  }
+
+  private void allowTagCreation() throws Exception {
+    grant(project, "refs/tags/*", tagType.createPermission, false, REGISTERED_USERS);
+  }
+
+  private void allowPushOnRefsTags() throws Exception {
+    removePushFromRefsTags();
+    grant(project, "refs/tags/*", Permission.PUSH, false, REGISTERED_USERS);
+  }
+
+  private void allowForcePushOnRefsTags() throws Exception {
+    removePushFromRefsTags();
+    grant(project, "refs/tags/*", Permission.PUSH, true, REGISTERED_USERS);
+  }
+
+  private void allowTagDeletion() throws Exception {
+    removePushFromRefsTags();
+    grant(project, "refs/tags/*", Permission.DELETE, true, REGISTERED_USERS);
+  }
+
+  private void removePushFromRefsTags() throws Exception {
+    removePermission(project, "refs/tags/*", Permission.PUSH);
+  }
+
+  private void commit(PersonIdent ident, String subject) throws Exception {
+    commitBuilder().ident(ident).message(subject + " (" + System.nanoTime() + ")").create();
+  }
+
+  private static String tagRef(String tagName) {
+    return RefNames.REFS_TAGS + tagName;
+  }
+}
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
index 839f166..a0c8275 100644
--- 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
@@ -14,6 +14,9 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static org.junit.Assert.fail;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
@@ -26,7 +29,11 @@
 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.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.client.ChangeStatus;
+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.ResourceNotFoundException;
@@ -34,6 +41,7 @@
 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.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import java.util.HashMap;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -46,12 +54,12 @@
 
 public class AccessIT extends AbstractDaemonTest {
 
-  private final String PROJECT_NAME = "newProject";
+  private static final String PROJECT_NAME = "newProject";
 
-  private final String REFS_ALL = Constants.R_REFS + "*";
-  private final String REFS_HEADS = Constants.R_HEADS + "*";
+  private static final String REFS_ALL = Constants.R_REFS + "*";
+  private static final String REFS_HEADS = Constants.R_HEADS + "*";
 
-  private final String LABEL_CODE_REVIEW = "Code-Review";
+  private static final String LABEL_CODE_REVIEW = "Code-Review";
 
   private String newProjectName;
   private ProjectApi pApi;
@@ -87,6 +95,69 @@
   }
 
   @Test
+  public void createAccessChange() throws Exception {
+    // User can see the branch
+    setApiUser(user);
+    gApi.projects().name(newProjectName).branch("refs/heads/master").get();
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    // Deny read to registered users.
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    read.exclusive = true;
+    accessSection.permissions.put(Permission.READ, read);
+    accessInput.add.put(REFS_HEADS, accessSection);
+
+    setApiUser(user);
+    ChangeInfo out = pApi.accessChange(accessInput);
+
+    assertThat(out.project).isEqualTo(newProjectName);
+    assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
+    assertThat(out.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(out.submitted).isNull();
+
+    setApiUser(admin);
+
+    ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
+    assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
+
+    ReviewInput reviewIn = new ReviewInput();
+    reviewIn.label("Code-Review", (short) 2);
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // check that the change took effect.
+    setApiUser(user);
+    try {
+      BranchInfo info = gApi.projects().name(newProjectName).branch("refs/heads/master").get();
+      fail("wanted failure, got " + newGson().toJson(info));
+    } catch (ResourceNotFoundException e) {
+      // OK.
+    }
+
+    // Restore.
+    accessInput.add.clear();
+    accessInput.remove.put(REFS_HEADS, accessSection);
+    setApiUser(user);
+
+    pApi.accessChange(accessInput);
+
+    setApiUser(admin);
+    out = pApi.accessChange(accessInput);
+
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // Now it works again.
+    setApiUser(user);
+    gApi.projects().name(newProjectName).branch("refs/heads/master").get();
+  }
+
+  @Test
   public void removePermission() throws Exception {
     // Add initial permission set
     ProjectAccessInput accessInput = newProjectAccessInput();
@@ -208,6 +279,51 @@
   }
 
   @Test
+  public void permissionsGroupMap() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo read = newPermissionInfo();
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    accessInput.add.put(REFS_ALL, accessSection);
+    ProjectAccessInfo result = pApi.access(accessInput);
+    assertThat(result.groups.keySet())
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+
+    // Check the name, which is what the UI cares about; exhaustive
+    // coverage of GroupInfo should be in groups REST API tests.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
+        .isEqualTo("Project Owners");
+    // Strip the ID, since it is in the key.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
+
+    // Get call returns groups too.
+    ProjectAccessInfo loggedInResult = pApi.access();
+    assertThat(loggedInResult.groups.keySet())
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+    assertThat(loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
+        .isEqualTo("Project Owners");
+    assertThat(loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
+
+    // PROJECT_OWNERS is invisible to anonymous user, so we strip it.
+    setApiUserAnonymous();
+    ProjectAccessInfo anonResult = pApi.access();
+    assertThat(anonResult.groups.keySet())
+        .containsExactly(SystemGroupBackend.ANONYMOUS_USERS.get());
+  }
+
+  @Test
   public void updateParentAsUser() throws Exception {
     // Create child
     String newParentProjectName = createProject(PROJECT_NAME + "PA").get();
@@ -218,7 +334,7 @@
 
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("not administrator");
+    exception.expectMessage("administrate server not permitted");
     gApi.projects().name(newProjectName).access(accessInput);
   }
 
@@ -279,7 +395,8 @@
 
   @Test
   public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
-    AccountGroup adminGroup = groupCache.get(new AccountGroup.NameKey("Administrators"));
+    InternalGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
 
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
@@ -308,7 +425,8 @@
 
   @Test
   public void removeGlobalCapabilityAsAdmin() throws Exception {
-    AccountGroup adminGroup = groupCache.get(new AccountGroup.NameKey("Administrators"));
+    InternalGroup adminGroup =
+        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
 
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
@@ -406,6 +524,34 @@
     assertThat(cfg.getString(access, refsFor, unknownPermission)).isEqualTo(registeredUsers);
   }
 
+  @Test
+  public void addAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Invalid Name: " + invalidRef);
+    pApi.access(accessInput);
+  }
+
+  @Test
+  public void createAccessChangeWithAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Invalid Name: " + invalidRef);
+    pApi.accessChange(accessInput);
+  }
+
   private ProjectAccessInput newProjectAccessInput() {
     ProjectAccessInput p = new ProjectAccessInput();
     p.add = new HashMap<>();
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
index ac022e9..fb79928 100644
--- 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
@@ -7,6 +7,7 @@
     labels = ["rest"],
     deps = [
         ":project",
+        ":push_tag_util",
         ":refassert",
     ],
 )
@@ -36,3 +37,14 @@
         "//lib:truth",
     ],
 )
+
+java_library(
+    name = "push_tag_util",
+    testonly = 1,
+    srcs = [
+        "AbstractPushTag.java",
+    ],
+    deps = [
+        "//gerrit-acceptance-tests:lib",
+    ],
+)
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 90d51e0..00a11de 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
@@ -46,7 +46,7 @@
         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");
+    assertThat(u.getMessage()).contains("contains banned commit");
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
deleted file mode 100644
index 61f14e4..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CommitIncludedInIT.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// 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.acceptance.rest.project;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.extensions.api.changes.IncludedInInfo;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.TagInput;
-import com.google.gerrit.reviewdb.client.Branch;
-import org.eclipse.jgit.lib.ObjectId;
-import org.junit.Test;
-
-public class CommitIncludedInIT extends AbstractDaemonTest {
-  @Test
-  public void includedInOpenChange() throws Exception {
-    Result result = createChange();
-    assertThat(getIncludedIn(result.getCommit().getId()).branches).isEmpty();
-    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
-  }
-
-  @Test
-  public void includedInMergedChange() throws Exception {
-    Result result = createChange();
-    gApi.changes()
-        .id(result.getChangeId())
-        .revision(result.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
-
-    assertThat(getIncludedIn(result.getCommit().getId()).branches).containsExactly("master");
-    assertThat(getIncludedIn(result.getCommit().getId()).tags).isEmpty();
-
-    grantTagPermissions();
-    gApi.projects().name(result.getChange().project().get()).tag("test-tag").create(new TagInput());
-
-    assertThat(getIncludedIn(result.getCommit().getId()).tags).containsExactly("test-tag");
-
-    createBranch(new Branch.NameKey(project.get(), "test-branch"));
-
-    assertThat(getIncludedIn(result.getCommit().getId()).branches)
-        .containsExactly("master", "test-branch");
-  }
-
-  private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
-    RestResponse r =
-        userRestSession.get("/projects/" + project.get() + "/commits/" + id.name() + "/in");
-    IncludedInInfo result = newGson().fromJson(r.getReader(), IncludedInInfo.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 2c74949..1b9a34a 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
@@ -44,7 +44,7 @@
   @Test
   public void createBranch_Forbidden() throws Exception {
     setApiUser(user);
-    assertCreateFails(AuthException.class);
+    assertCreateFails(AuthException.class, "create not permitted for refs/heads/test");
   }
 
   @Test
@@ -68,7 +68,7 @@
   @Test
   public void createBranchByAdminCreateReferenceBlocked_Forbidden() throws Exception {
     blockCreateReference();
-    assertCreateFails(AuthException.class);
+    assertCreateFails(AuthException.class, "create not permitted for refs/heads/test");
   }
 
   @Test
@@ -76,15 +76,15 @@
     grantOwner();
     blockCreateReference();
     setApiUser(user);
-    assertCreateFails(AuthException.class);
+    assertCreateFails(AuthException.class, "create not permitted for refs/heads/test");
   }
 
   private void blockCreateReference() throws Exception {
-    block(Permission.CREATE, ANONYMOUS_USERS, "refs/*");
+    block("refs/*", Permission.CREATE, ANONYMOUS_USERS);
   }
 
   private void grantOwner() throws Exception {
-    allow(Permission.OWNER, REGISTERED_USERS, "refs/*");
+    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
   }
 
   private BranchApi branch() throws Exception {
@@ -96,8 +96,16 @@
     assertThat(created.ref).isEqualTo(Constants.R_HEADS + branch.getShortName());
   }
 
-  private void assertCreateFails(Class<? extends RestApiException> errType) throws Exception {
+  private void assertCreateFails(Class<? extends RestApiException> errType, String errMsg)
+      throws Exception {
+    if (errMsg != null) {
+      exception.expectMessage(errMsg);
+    }
     exception.expect(errType);
     branch().create(new BranchInput());
   }
+
+  private void assertCreateFails(Class<? extends RestApiException> errType) throws Exception {
+    assertCreateFails(errType, null);
+  }
 }
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 78c66d6..0409fbc 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
@@ -15,9 +15,11 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectOwners;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.net.HttpHeaders;
@@ -41,6 +43,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Collections;
 import java.util.Set;
+import org.apache.http.HttpStatus;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
@@ -57,6 +60,11 @@
     r.assertCreated();
     ProjectInfo p = newGson().fromJson(r.getReader(), ProjectInfo.class);
     assertThat(p.name).isEqualTo(newProjectName);
+
+    // Check that we populate the label data in the HTTP path. See GetProjectIT#getProject
+    // for more extensive coverage of the LabelTypeInfo.
+    assertThat(p.labels).hasSize(1);
+
     ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
     assertThat(projectState).isNotNull();
     assertProjectInfo(projectState.getProject(), p);
@@ -79,7 +87,15 @@
   @Test
   @UseLocalDisk
   public void createProjectHttpWithUnreasonableName_BadRequest() throws Exception {
-    adminRestSession.put("/projects/" + Url.encode(name("invalid/../name"))).assertBadRequest();
+    ImmutableList<String> forbiddenStrings =
+        ImmutableList.of(
+            "/../", "/./", "//", ".git/", "?", "%", "*", ":", "<", ">", "|", "$", "/+", "~");
+    for (String s : forbiddenStrings) {
+      String projectName = name("invalid" + s + "name");
+      assertWithMessage("Expected status code for " + projectName + " to be 400.")
+          .that(adminRestSession.put("/projects/" + Url.encode(projectName)).getStatusCode())
+          .isEqualTo(HttpStatus.SC_BAD_REQUEST);
+    }
   }
 
   @Test
@@ -175,7 +191,11 @@
     in.owners.add(SystemGroupBackend.REGISTERED_USERS.get()); // by UUID
     in.owners.add(
         Integer.toString(
-            groupCache.get(new AccountGroup.NameKey("Administrators")).getId().get())); // by ID
+            groupCache
+                .get(new AccountGroup.NameKey("Administrators"))
+                .orElse(null)
+                .getId()
+                .get())); // by ID
     gApi.projects().create(in);
     ProjectState projectState = projectCache.get(new Project.NameKey(newProjectName));
     Set<AccountGroup.UUID> expectedOwnerIds = Sets.newHashSetWithExpectedSize(3);
@@ -277,7 +297,7 @@
   }
 
   private AccountGroup.UUID groupUuid(String groupName) {
-    return groupCache.get(new AccountGroup.NameKey(groupName)).getGroupUUID();
+    return groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null).getGroupUUID();
   }
 
   private void assertHead(String projectName, String expectedRef) throws Exception {
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 66c61f7..ce30cd5 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
@@ -25,45 +25,46 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.RefNames;
 import org.junit.Before;
 import org.junit.Test;
 
 public class DeleteBranchIT extends AbstractDaemonTest {
 
-  private Branch.NameKey branch;
+  private Branch.NameKey testBranch;
 
   @Before
   public void setUp() throws Exception {
     project = createProject(name("p"));
-    branch = new Branch.NameKey(project, "test");
-    branch().create(new BranchInput());
+    testBranch = new Branch.NameKey(project, "test");
+    branch(testBranch).create(new BranchInput());
   }
 
   @Test
   public void deleteBranch_Forbidden() throws Exception {
     setApiUser(user);
-    assertDeleteForbidden();
+    assertDeleteForbidden(testBranch);
   }
 
   @Test
   public void deleteBranchByAdmin() throws Exception {
-    assertDeleteSucceeds();
+    assertDeleteSucceeds(testBranch);
   }
 
   @Test
   public void deleteBranchByProjectOwner() throws Exception {
     grantOwner();
     setApiUser(user);
-    assertDeleteSucceeds();
+    assertDeleteSucceeds(testBranch);
   }
 
   @Test
   public void deleteBranchByAdminForcePushBlocked() throws Exception {
     blockForcePush();
-    assertDeleteSucceeds();
+    assertDeleteSucceeds(testBranch);
   }
 
   @Test
@@ -71,85 +72,105 @@
     grantOwner();
     blockForcePush();
     setApiUser(user);
-    assertDeleteForbidden();
+    assertDeleteForbidden(testBranch);
   }
 
   @Test
   public void deleteBranchByUserWithForcePushPermission() throws Exception {
     grantForcePush();
     setApiUser(user);
-    assertDeleteSucceeds();
+    assertDeleteSucceeds(testBranch);
   }
 
   @Test
   public void deleteBranchByUserWithDeletePermission() throws Exception {
     grantDelete();
     setApiUser(user);
-    assertDeleteSucceeds();
+    assertDeleteSucceeds(testBranch);
   }
 
   @Test
   public void deleteBranchByRestWithoutRefsHeadsPrefix() throws Exception {
     grantDelete();
-    String ref = branch.getShortName();
+    String ref = testBranch.getShortName();
     assertThat(ref).doesNotMatch(R_HEADS);
-    assertDeleteByRestSucceeds(ref);
+    assertDeleteByRestSucceeds(testBranch, ref);
   }
 
   @Test
-  public void deleteBranchByRestWithEncodedFullName() throws Exception {
+  public void deleteBranchByRestWithFullName() throws Exception {
     grantDelete();
-    assertDeleteByRestSucceeds(Url.encode(branch.get()));
+    assertDeleteByRestSucceeds(testBranch, testBranch.get());
   }
 
   @Test
   public void deleteBranchByRestFailsWithUnencodedFullName() throws Exception {
     grantDelete();
     RestResponse r =
-        userRestSession.delete("/projects/" + project.get() + "/branches/" + branch.get());
+        userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.get());
     r.assertNotFound();
-    branch().get();
+    branch(testBranch).get();
+  }
+
+  @Test
+  public void deleteMetaBranch() throws Exception {
+    String metaRef = RefNames.REFS_META + "foo";
+    allow(metaRef, Permission.CREATE, REGISTERED_USERS);
+    allow(metaRef, Permission.PUSH, REGISTERED_USERS);
+
+    Branch.NameKey metaBranch = new Branch.NameKey(project, metaRef);
+    branch(metaBranch).create(new BranchInput());
+
+    grantDelete();
+    assertDeleteByRestSucceeds(metaBranch, metaRef);
   }
 
   private void blockForcePush() throws Exception {
-    block(Permission.PUSH, ANONYMOUS_USERS, "refs/heads/*").setForce(true);
+    block("refs/heads/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
   }
 
   private void grantForcePush() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/*", true, ANONYMOUS_USERS);
+    grant(project, "refs/heads/*", Permission.PUSH, true, ANONYMOUS_USERS);
   }
 
   private void grantDelete() throws Exception {
-    allow(Permission.DELETE, ANONYMOUS_USERS, "refs/*");
+    allow("refs/*", Permission.DELETE, ANONYMOUS_USERS);
   }
 
   private void grantOwner() throws Exception {
-    allow(Permission.OWNER, REGISTERED_USERS, "refs/*");
+    allow("refs/*", Permission.OWNER, REGISTERED_USERS);
   }
 
-  private BranchApi branch() throws Exception {
+  private BranchApi branch(Branch.NameKey branch) throws Exception {
     return gApi.projects().name(branch.getParentKey().get()).branch(branch.get());
   }
 
-  private void assertDeleteByRestSucceeds(String ref) throws Exception {
-    RestResponse r = userRestSession.delete("/projects/" + project.get() + "/branches/" + ref);
+  private void assertDeleteByRestSucceeds(Branch.NameKey branch, String ref) throws Exception {
+    RestResponse r =
+        userRestSession.delete(
+            "/projects/"
+                + IdString.fromDecoded(project.get()).encoded()
+                + "/branches/"
+                + IdString.fromDecoded(ref).encoded());
     r.assertNoContent();
     exception.expect(ResourceNotFoundException.class);
-    branch().get();
+    branch(branch).get();
   }
 
-  private void assertDeleteSucceeds() throws Exception {
-    String branchRev = branch().get().revision;
-    branch().delete();
+  private void assertDeleteSucceeds(Branch.NameKey branch) throws Exception {
+    assertThat(branch(branch).get().canDelete).isTrue();
+    String branchRev = branch(branch).get().revision;
+    branch(branch).delete();
     eventRecorder.assertRefUpdatedEvents(
         project.get(), branch.get(), null, branchRev, branchRev, null);
     exception.expect(ResourceNotFoundException.class);
-    branch().get();
+    branch(branch).get();
   }
 
-  private void assertDeleteForbidden() throws Exception {
+  private void assertDeleteForbidden(Branch.NameKey branch) throws Exception {
+    assertThat(branch(branch).get().canDelete).isNull();
     exception.expect(AuthException.class);
-    exception.expectMessage("Cannot delete branch");
-    branch().delete();
+    exception.expectMessage("delete not permitted");
+    branch(branch).delete();
   }
 }
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
index 7580a16..c61e8fa 100644
--- 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
@@ -15,15 +15,17 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefNames;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
+import static org.eclipse.jgit.lib.Constants.R_REFS;
 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.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
@@ -33,6 +35,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import java.util.HashMap;
 import java.util.List;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
@@ -40,10 +43,12 @@
 @NoHttpd
 public class DeleteBranchesIT extends AbstractDaemonTest {
   private static final ImmutableList<String> BRANCHES =
-      ImmutableList.of("refs/heads/test-1", "refs/heads/test-2", "test-3");
+      ImmutableList.of("refs/heads/test-1", "refs/heads/test-2", "test-3", "refs/meta/foo");
 
   @Before
   public void setUp() throws Exception {
+    allow("refs/*", Permission.CREATE, REGISTERED_USERS);
+    allow("refs/*", Permission.PUSH, REGISTERED_USERS);
     for (String name : BRANCHES) {
       project().branch(name).create(new BranchInput());
     }
@@ -56,7 +61,7 @@
     DeleteBranchesInput input = new DeleteBranchesInput();
     input.branches = BRANCHES;
     project().deleteBranches(input);
-    assertBranchesDeleted();
+    assertBranchesDeleted(BRANCHES);
     assertRefUpdatedEvents(initialRevisions);
   }
 
@@ -106,7 +111,7 @@
           .hasMessageThat()
           .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
     }
-    assertBranchesDeleted();
+    assertBranchesDeleted(BRANCHES);
   }
 
   @Test
@@ -125,7 +130,7 @@
           .hasMessageThat()
           .isEqualTo(errorMessageForBranches(ImmutableList.of("refs/heads/does-not-exist")));
     }
-    assertBranchesDeleted();
+    assertBranchesDeleted(BRANCHES);
   }
 
   @Test
@@ -182,7 +187,7 @@
   }
 
   private String prefixRef(String ref) {
-    return ref.startsWith(R_HEADS) ? ref : R_HEADS + ref;
+    return ref.startsWith(R_REFS) ? ref : R_HEADS + ref;
   }
 
   private ProjectApi project() throws Exception {
@@ -192,10 +197,18 @@
   private void assertBranches(List<String> branches) throws Exception {
     List<String> expected = Lists.newArrayList("HEAD", RefNames.REFS_CONFIG, "refs/heads/master");
     expected.addAll(branches.stream().map(b -> prefixRef(b)).collect(toList()));
-    assertRefNames(expected, project().branches().get());
+    try (Repository repo = repoManager.openRepository(project)) {
+      for (String branch : expected) {
+        assertThat(repo.exactRef(branch)).isNotNull();
+      }
+    }
   }
 
-  private void assertBranchesDeleted() throws Exception {
-    assertBranches(ImmutableList.<String>of());
+  private void assertBranchesDeleted(List<String> branches) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      for (String branch : branches) {
+        assertThat(repo.exactRef(branch)).isNull();
+      }
+    }
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 5608fb6..0cbbe44 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+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 org.eclipse.jgit.lib.Constants.R_TAGS;
@@ -22,6 +23,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.Permission;
 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.ResourceNotFoundException;
@@ -91,19 +93,19 @@
   }
 
   private void blockForcePush() throws Exception {
-    block(Permission.PUSH, ANONYMOUS_USERS, "refs/tags/*").setForce(true);
+    block("refs/tags/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
   }
 
   private void grantForcePush() throws Exception {
-    grant(Permission.PUSH, project, "refs/tags/*", true, ANONYMOUS_USERS);
+    grant(project, "refs/tags/*", Permission.PUSH, true, ANONYMOUS_USERS);
   }
 
   private void grantDelete() throws Exception {
-    allow(Permission.DELETE, ANONYMOUS_USERS, "refs/tags/*");
+    allow("refs/tags/*", Permission.DELETE, ANONYMOUS_USERS);
   }
 
   private void grantOwner() throws Exception {
-    allow(Permission.OWNER, REGISTERED_USERS, "refs/tags/*");
+    allow("refs/tags/*", Permission.OWNER, REGISTERED_USERS);
   }
 
   private TagApi tag() throws Exception {
@@ -111,7 +113,9 @@
   }
 
   private void assertDeleteSucceeds() throws Exception {
-    String tagRev = tag().get().revision;
+    TagInfo tagInfo = tag().get();
+    assertThat(tagInfo.canDelete).isTrue();
+    String tagRev = tagInfo.revision;
     tag().delete();
     eventRecorder.assertRefUpdatedEvents(project.get(), TAG, null, tagRev, tagRev, null);
     exception.expect(ResourceNotFoundException.class);
@@ -119,8 +123,9 @@
   }
 
   private void assertDeleteForbidden() throws Exception {
+    assertThat(tag().get().canDelete).isNull();
     exception.expect(AuthException.class);
-    exception.expectMessage("Cannot delete tag");
+    exception.expectMessage("delete not permitted");
     tag().delete();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
index 53e5b55..989050c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
@@ -16,8 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.common.LabelTypeInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import org.junit.Test;
@@ -30,6 +32,19 @@
     String name = project.get();
     ProjectInfo p = gApi.projects().name(name).get();
     assertThat(p.name).isEqualTo(name);
+
+    assertThat(p.labels).hasSize(1);
+    LabelTypeInfo l = p.labels.get("Code-Review");
+
+    ImmutableMap<String, String> want =
+        ImmutableMap.of(
+            " 0", "No score",
+            "-1", "I would prefer this is not merged as is",
+            "-2", "This shall not be merged",
+            "+1", "Looks good to me, but someone else must approve",
+            "+2", "Looks good to me, approved");
+    assertThat(l.values).isEqualTo(want);
+    assertThat(l.defaultValue).isEqualTo(0);
   }
 
   @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 a31a34c..a854764 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
@@ -22,15 +22,20 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.Projects.ListRequest;
 import com.google.gerrit.extensions.api.projects.Projects.ListRequest.FilterType;
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.Util;
 import com.google.inject.Inject;
 import java.util.List;
@@ -38,6 +43,7 @@
 import org.junit.Test;
 
 @NoHttpd
+@Sandboxed
 public class ListProjectsIT extends AbstractDaemonTest {
 
   @Inject private AllUsersName allUsers;
@@ -87,6 +93,7 @@
 
   @Test
   public void listProjectsWithLimit() throws Exception {
+    ProjectCacheImpl projectCacheImpl = (ProjectCacheImpl) projectCache;
     for (int i = 0; i < 5; i++) {
       createProject("someProject" + i);
     }
@@ -94,9 +101,12 @@
     String p = name("");
     // 5, plus p which was automatically created.
     int n = 6;
+    projectCacheImpl.evictAllByName();
     for (int i = 1; i <= n + 2; i++) {
       assertThatNameList(gApi.projects().list().withPrefix(p).withLimit(i).get())
           .hasSize(Math.min(i, n));
+      assertThat(projectCacheImpl.sizeAllByName())
+          .isAtMost((long) (i + 2)); // 2 = AllProjects + AllUsers
     }
   }
 
@@ -190,6 +200,56 @@
         .inOrder();
   }
 
+  @Test
+  public void listParentCandidates() throws Exception {
+    Map<String, ProjectInfo> result =
+        gApi.projects().list().withType(FilterType.PARENT_CANDIDATES).getAsMap();
+    assertThat(result).hasSize(1);
+    assertThat(result).containsKey(allProjects.get());
+
+    // Create a new project with 'project' as parent
+    Project.NameKey testProject = createProject(name("test"), project);
+
+    // Parent candidates are All-Projects and 'project'
+    assertThatNameList(filter(gApi.projects().list().withType(FilterType.PARENT_CANDIDATES).get()))
+        .containsExactly(allProjects, project)
+        .inOrder();
+
+    // All projects are listed
+    assertThatNameList(filter(gApi.projects().list().get()))
+        .containsExactly(allProjects, allUsers, testProject, project)
+        .inOrder();
+  }
+
+  @Test
+  public void listWithHiddenProject() throws Exception {
+    Project.NameKey hidden = createProject("project-to-hide");
+
+    // The project is included because it was not hidden yet
+    assertThatNameList(gApi.projects().list().get())
+        .containsExactly(allProjects, allUsers, project, hidden)
+        .inOrder();
+
+    // Hide the project
+    ConfigInput input = new ConfigInput();
+    input.state = ProjectState.HIDDEN;
+    ConfigInfo info = gApi.projects().name(hidden.get()).config(input);
+    assertThat(info.state).isEqualTo(input.state);
+
+    // Project is still accessible directly
+    gApi.projects().name(hidden.get()).get();
+
+    // But is not included in the list
+    assertThatNameList(gApi.projects().list().get())
+        .containsExactly(allProjects, allUsers, project)
+        .inOrder();
+
+    // ALL filter applies to type, and doesn't include hidden state
+    assertThatNameList(gApi.projects().list().withType(FilterType.ALL).get())
+        .containsExactly(allProjects, allUsers, project)
+        .inOrder();
+  }
+
   private void assertBadRequest(ListRequest req) throws Exception {
     try {
       req.get();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushAnnotatedTagIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushAnnotatedTagIT.java
new file mode 100644
index 0000000..24c8ed0
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushAnnotatedTagIT.java
@@ -0,0 +1,23 @@
+// 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.acceptance.rest.project;
+
+public class PushAnnotatedTagIT extends AbstractPushTag {
+
+  @Override
+  protected TagType getTagType() {
+    return TagType.ANNOTATED;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushLightweightTagIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushLightweightTagIT.java
new file mode 100644
index 0000000..20d83a0
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushLightweightTagIT.java
@@ -0,0 +1,23 @@
+// 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.acceptance.rest.project;
+
+public class PushLightweightTagIT extends AbstractPushTag {
+
+  @Override
+  protected TagType getTagType() {
+    return TagType.LIGHTWEIGHT;
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java
deleted file mode 100644
index 7ed15f4..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java
+++ /dev/null
@@ -1,275 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF 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.GitUtil.createAnnotatedTag;
-import static com.google.gerrit.acceptance.GitUtil.deleteRef;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static com.google.gerrit.acceptance.GitUtil.updateAnnotatedTag;
-import static com.google.gerrit.acceptance.rest.project.PushTagIT.TagType.ANNOTATED;
-import static com.google.gerrit.acceptance.rest.project.PushTagIT.TagType.LIGHTWEIGHT;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-
-import com.google.common.base.MoreObjects;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.reviewdb.client.RefNames;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.PushResult;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class PushTagIT extends AbstractDaemonTest {
-  enum TagType {
-    LIGHTWEIGHT(Permission.CREATE),
-    ANNOTATED(Permission.CREATE_TAG);
-
-    final String createPermission;
-
-    TagType(String createPermission) {
-      this.createPermission = createPermission;
-    }
-  }
-
-  private RevCommit initialHead;
-
-  @Before
-  public void setup() throws Exception {
-    // clone with user to avoid inherited tag permissions of admin user
-    testRepo = cloneProject(project, user);
-
-    initialHead = getRemoteHead();
-  }
-
-  @Test
-  public void createTagForExistingCommit() throws Exception {
-    for (TagType tagType : TagType.values()) {
-      pushTagForExistingCommit(tagType, Status.REJECTED_OTHER_REASON);
-
-      allowTagCreation(tagType);
-      pushTagForExistingCommit(tagType, Status.OK);
-
-      allowPushOnRefsTags();
-      pushTagForExistingCommit(tagType, Status.OK);
-
-      removePushFromRefsTags();
-    }
-  }
-
-  @Test
-  public void createTagForNewCommit() throws Exception {
-    for (TagType tagType : TagType.values()) {
-      pushTagForNewCommit(tagType, Status.REJECTED_OTHER_REASON);
-
-      allowTagCreation(tagType);
-      pushTagForNewCommit(tagType, Status.REJECTED_OTHER_REASON);
-
-      allowPushOnRefsTags();
-      pushTagForNewCommit(tagType, Status.OK);
-
-      removePushFromRefsTags();
-    }
-  }
-
-  @Test
-  public void fastForward() throws Exception {
-    for (TagType tagType : TagType.values()) {
-      allowTagCreation(tagType);
-      String tagName = pushTagForExistingCommit(tagType, Status.OK);
-
-      fastForwardTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-      fastForwardTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-
-      allowTagDeletion();
-      fastForwardTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-      fastForwardTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-
-      allowPushOnRefsTags();
-      Status expectedStatus = tagType == ANNOTATED ? Status.REJECTED_OTHER_REASON : Status.OK;
-      fastForwardTagToExistingCommit(tagType, tagName, expectedStatus);
-      fastForwardTagToNewCommit(tagType, tagName, expectedStatus);
-
-      allowForcePushOnRefsTags();
-      fastForwardTagToExistingCommit(tagType, tagName, Status.OK);
-      fastForwardTagToNewCommit(tagType, tagName, Status.OK);
-
-      removePushFromRefsTags();
-    }
-  }
-
-  @Test
-  public void forceUpdate() throws Exception {
-    for (TagType tagType : TagType.values()) {
-      allowTagCreation(tagType);
-      String tagName = pushTagForExistingCommit(tagType, Status.OK);
-
-      forceUpdateTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-      forceUpdateTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-
-      allowPushOnRefsTags();
-      forceUpdateTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-      forceUpdateTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-
-      allowTagDeletion();
-      forceUpdateTagToExistingCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-      forceUpdateTagToNewCommit(tagType, tagName, Status.REJECTED_OTHER_REASON);
-
-      allowForcePushOnRefsTags();
-      forceUpdateTagToExistingCommit(tagType, tagName, Status.OK);
-      forceUpdateTagToNewCommit(tagType, tagName, Status.OK);
-
-      removePushFromRefsTags();
-    }
-  }
-
-  @Test
-  public void delete() throws Exception {
-    for (TagType tagType : TagType.values()) {
-      allowTagCreation(tagType);
-      String tagName = pushTagForExistingCommit(tagType, Status.OK);
-
-      pushTagDeletion(tagType, tagName, Status.REJECTED_OTHER_REASON);
-
-      allowPushOnRefsTags();
-      pushTagDeletion(tagType, tagName, Status.REJECTED_OTHER_REASON);
-    }
-
-    allowForcePushOnRefsTags();
-    for (TagType tagType : TagType.values()) {
-      String tagName = pushTagForExistingCommit(tagType, Status.OK);
-      pushTagDeletion(tagType, tagName, Status.OK);
-    }
-
-    removePushFromRefsTags();
-    allowTagDeletion();
-    for (TagType tagType : TagType.values()) {
-      String tagName = pushTagForExistingCommit(tagType, Status.OK);
-      pushTagDeletion(tagType, tagName, Status.OK);
-    }
-  }
-
-  private String pushTagForExistingCommit(TagType tagType, Status expectedStatus) throws Exception {
-    return pushTag(tagType, null, false, false, expectedStatus);
-  }
-
-  private String pushTagForNewCommit(TagType tagType, Status expectedStatus) throws Exception {
-    return pushTag(tagType, null, true, false, expectedStatus);
-  }
-
-  private void fastForwardTagToExistingCommit(
-      TagType tagType, String tagName, Status expectedStatus) throws Exception {
-    pushTag(tagType, tagName, false, false, expectedStatus);
-  }
-
-  private void fastForwardTagToNewCommit(TagType tagType, String tagName, Status expectedStatus)
-      throws Exception {
-    pushTag(tagType, tagName, true, false, expectedStatus);
-  }
-
-  private void forceUpdateTagToExistingCommit(
-      TagType tagType, String tagName, Status expectedStatus) throws Exception {
-    pushTag(tagType, tagName, false, true, expectedStatus);
-  }
-
-  private void forceUpdateTagToNewCommit(TagType tagType, String tagName, Status expectedStatus)
-      throws Exception {
-    pushTag(tagType, tagName, true, true, expectedStatus);
-  }
-
-  private String pushTag(
-      TagType tagType, String tagName, boolean newCommit, boolean force, Status expectedStatus)
-      throws Exception {
-    if (force) {
-      testRepo.reset(initialHead);
-    }
-    commit(user.getIdent(), "subject");
-
-    boolean createTag = tagName == null;
-    tagName = MoreObjects.firstNonNull(tagName, "v1_" + System.nanoTime());
-    switch (tagType) {
-      case LIGHTWEIGHT:
-        break;
-      case ANNOTATED:
-        if (createTag) {
-          createAnnotatedTag(testRepo, tagName, user.getIdent());
-        } else {
-          updateAnnotatedTag(testRepo, tagName, user.getIdent());
-        }
-        break;
-      default:
-        throw new IllegalStateException("unexpected tag type: " + tagType);
-    }
-
-    if (!newCommit) {
-      grant(Permission.SUBMIT, project, "refs/for/refs/heads/master", false, REGISTERED_USERS);
-      pushHead(testRepo, "refs/for/master%submit");
-    }
-
-    String tagRef = tagRef(tagName);
-    PushResult r =
-        tagType == LIGHTWEIGHT
-            ? pushHead(testRepo, tagRef, false, force)
-            : GitUtil.pushTag(testRepo, tagName, !createTag);
-    RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
-    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
-    return tagName;
-  }
-
-  private void pushTagDeletion(TagType tagType, String tagName, Status expectedStatus)
-      throws Exception {
-    String tagRef = tagRef(tagName);
-    PushResult r = deleteRef(testRepo, tagRef);
-    RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
-    assertThat(refUpdate.getStatus()).named(tagType.name()).isEqualTo(expectedStatus);
-  }
-
-  private void allowTagCreation(TagType tagType) throws Exception {
-    grant(tagType.createPermission, project, "refs/tags/*", false, REGISTERED_USERS);
-  }
-
-  private void allowPushOnRefsTags() throws Exception {
-    removePushFromRefsTags();
-    grant(Permission.PUSH, project, "refs/tags/*", false, REGISTERED_USERS);
-  }
-
-  private void allowForcePushOnRefsTags() throws Exception {
-    removePushFromRefsTags();
-    grant(Permission.PUSH, project, "refs/tags/*", true, REGISTERED_USERS);
-  }
-
-  private void allowTagDeletion() throws Exception {
-    removePushFromRefsTags();
-    grant(Permission.DELETE, project, "refs/tags/*", true, REGISTERED_USERS);
-  }
-
-  private void removePushFromRefsTags() throws Exception {
-    removePermission(Permission.PUSH, project, "refs/tags/*");
-  }
-
-  private void commit(PersonIdent ident, String subject) throws Exception {
-    commitBuilder().ident(ident).message(subject + " (" + System.nanoTime() + ")").create();
-  }
-
-  private static String tagRef(String tagName) {
-    return RefNames.REFS_TAGS + tagName;
-  }
-}
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 ce43b08..ed791a2 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
@@ -187,7 +187,7 @@
 
     setApiUser(user);
     result = tag(input.ref).get();
-    assertThat(result.canDelete).isFalse();
+    assertThat(result.canDelete).isNull();
 
     eventRecorder.assertRefUpdatedEvents(project.get(), result.ref, null, result.revision);
   }
@@ -246,17 +246,17 @@
 
   @Test
   public void createTagNotAllowed() throws Exception {
-    block(Permission.CREATE, REGISTERED_USERS, R_TAGS + "*");
+    block(R_TAGS + "*", Permission.CREATE, REGISTERED_USERS);
     TagInput input = new TagInput();
     input.ref = "test";
     exception.expect(AuthException.class);
-    exception.expectMessage("Cannot create tag \"" + R_TAGS + "test\"");
+    exception.expectMessage("create not permitted");
     tag(input.ref).create(input);
   }
 
   @Test
   public void createAnnotatedTagNotAllowed() throws Exception {
-    block(Permission.CREATE_TAG, REGISTERED_USERS, R_TAGS + "*");
+    block(R_TAGS + "*", Permission.CREATE_TAG, REGISTERED_USERS);
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = "annotation";
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/BUILD
new file mode 100644
index 0000000..f47ac46
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/BUILD
@@ -0,0 +1,7 @@
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_revision",
+    labels = ["rest"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
new file mode 100644
index 0000000..220254b
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
@@ -0,0 +1,110 @@
+// 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.acceptance.rest.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+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.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.util.Base64;
+import org.junit.Test;
+
+public class RevisionIT extends AbstractDaemonTest {
+  @Test
+  public void contentOfParent() throws Exception {
+    String parentContent = "parent content";
+    PushOneCommit.Result parent = createChange("Parent change", FILE_NAME, parentContent);
+    parent.assertOkStatus();
+
+    gApi.changes().id(parent.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(parent.getChangeId()).current().submit();
+
+    PushOneCommit.Result child = createChange("Child change", FILE_NAME, FILE_CONTENT);
+    child.assertOkStatus();
+    assertContent(child, FILE_NAME, FILE_CONTENT);
+
+    RestResponse response =
+        adminRestSession.get(
+            "/changes/"
+                + child.getChangeId()
+                + "/revisions/current/files/"
+                + FILE_NAME
+                + "/content?parent=1");
+    response.assertOK();
+    assertThat(new String(Base64.decode(response.getEntityContent()), UTF_8))
+        .isEqualTo(parentContent);
+  }
+
+  @Test
+  public void contentOfInvalidParent() throws Exception {
+    String parentContent = "parent content";
+    PushOneCommit.Result parent = createChange("Parent change", FILE_NAME, parentContent);
+    parent.assertOkStatus();
+
+    gApi.changes().id(parent.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(parent.getChangeId()).current().submit();
+
+    PushOneCommit.Result child = createChange("Child change", FILE_NAME, FILE_CONTENT);
+    child.assertOkStatus();
+    assertContent(child, FILE_NAME, FILE_CONTENT);
+
+    RestResponse response =
+        adminRestSession.get(
+            "/changes/"
+                + child.getChangeId()
+                + "/revisions/current/files/"
+                + FILE_NAME
+                + "/content?parent=10");
+    response.assertBadRequest();
+    assertThat(response.getEntityContent()).isEqualTo("invalid parent");
+  }
+
+  @Test
+  public void getReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ObjectId ps1Commit = r.getCommit();
+    r = amendChange(r.getChangeId());
+    ObjectId ps2Commit = r.getCommit();
+
+    ChangeInfo info1 = checkRevisionReview(r, 1, ps1Commit);
+    assertThat(info1.currentRevision).isNull();
+
+    ChangeInfo info2 = checkRevisionReview(r, 2, ps2Commit);
+    assertThat(info2.currentRevision).isEqualTo(ps2Commit.name());
+  }
+
+  private ChangeInfo checkRevisionReview(
+      PushOneCommit.Result r, int psNum, ObjectId expectedRevision) throws Exception {
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+
+    RestResponse response =
+        adminRestSession.get("/changes/" + r.getChangeId() + "/revisions/" + psNum + "/review");
+    response.assertOK();
+    ChangeInfo info = newGson().fromJson(response.getReader(), ChangeInfo.class);
+
+    // Check for DETAILED_ACCOUNTS, DETAILED_LABELS, and specified revision.
+    assertThat(info.owner.name).isNotNull();
+    assertThat(info.labels.get("Code-Review").all).hasSize(1);
+    assertThat(info.revisions.keySet()).containsExactly(expectedRevision.name());
+    return info;
+  }
+}
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 7e95da6..1c79340 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
@@ -15,17 +15,22 @@
 package com.google.gerrit.acceptance.server.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.toList;
 
 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;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 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;
@@ -34,27 +39,43 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+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.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.RefNames;
 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.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.DeleteCommentRewriter;
 import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -67,6 +88,8 @@
 
   @Inject private FakeEmailSender email;
 
+  @Inject private ChangeNoteUtil noteUtil;
+
   private final Integer[] lines = {0, 1};
 
   @Before
@@ -96,6 +119,11 @@
       assertThat(result).hasSize(1);
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
       assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+      List<CommentInfo> list = getDraftCommentsAsList(changeId);
+      assertThat(list).hasSize(1);
+      actual = list.get(0);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
     }
   }
 
@@ -118,6 +146,10 @@
       assertThat(result).hasSize(1);
       assertThat(Lists.transform(result.get(path), infoToDraft(path)))
           .containsExactly(c1, c2, c3, c4);
+
+      List<CommentInfo> list = getDraftCommentsAsList(changeId);
+      assertThat(list).hasSize(4);
+      assertThat(Lists.transform(list, infoToDraft(path))).containsExactly(c1, c2, c3, c4);
     }
   }
 
@@ -220,6 +252,9 @@
       assertThat(result).isNotEmpty();
       assertThat(Lists.transform(result.get(file), infoToInput(file)))
           .containsExactly(c1, c2, c3, c4);
+
+      List<CommentInfo> list = getPublishedCommentsAsList(changeId);
+      assertThat(Lists.transform(list, infoToInput(file))).containsExactly(c1, c2, c3, c4);
     }
 
     // for the commit message comments on the auto-merge are not possible
@@ -238,6 +273,9 @@
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
       assertThat(Lists.transform(result.get(file), infoToInput(file))).containsExactly(c1, c2, c3);
+
+      List<CommentInfo> list = getPublishedCommentsAsList(changeId);
+      assertThat(Lists.transform(list, infoToInput(file))).containsExactly(c1, c2, c3);
     }
   }
 
@@ -262,6 +300,7 @@
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
     assertThat(getPublishedComments(changeId, revId)).isEmpty();
+    assertThat(getPublishedCommentsAsList(changeId)).isEmpty();
 
     List<CommentInput> expectedComments = new ArrayList<>();
     for (Integer line : lines) {
@@ -278,6 +317,10 @@
     List<CommentInfo> actualComments = result.get(file);
     assertThat(Lists.transform(actualComments, infoToInput(file)))
         .containsExactlyElementsIn(expectedComments);
+
+    List<CommentInfo> list = getPublishedCommentsAsList(changeId);
+    assertThat(Lists.transform(list, infoToInput(file)))
+        .containsExactlyElementsIn(expectedComments);
   }
 
   @Test
@@ -380,7 +423,7 @@
       ChangeResource changeRsrc =
           changes.get().parse(TopLevelResource.INSTANCE, IdString.fromDecoded(changeId));
       RevisionResource revRsrc = revisions.parse(changeRsrc, IdString.fromDecoded(revId));
-      postReview.get().apply(revRsrc, input, timestamp);
+      postReview.get().apply(batchUpdateFactory, revRsrc, input, timestamp);
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
@@ -513,8 +556,14 @@
 
   @Test
   public void publishCommentsAllRevisions() throws Exception {
-    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
 
+    pushFactory
+        .create(db, admin.getIdent(), testRepo, SUBJECT, FILE_NAME, "old\ncontent\n", changeId)
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 =
         pushFactory
             .create(
@@ -523,18 +572,18 @@
                 testRepo,
                 SUBJECT,
                 FILE_NAME,
-                "new\ncntent\n",
+                "new \ncntent\n",
                 r1.getChangeId())
             .to("refs/for/master");
 
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
+        newDraft(FILE_NAME, Side.REVISION, range(1, 0, 4), "nit: trailing whitespace"));
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 2, "what happened to this?"));
+        newDraft(FILE_NAME, Side.PARENT, range(1, 0, 3), "why is this removed?"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
@@ -542,15 +591,15 @@
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
+        newDraft(FILE_NAME, Side.REVISION, range(2, 0, 6), "typo: content"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
+        newDraft(FILE_NAME, Side.PARENT, 1, "line comment 1 on base"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
+        newDraft(FILE_NAME, Side.PARENT, 2, "line comment 2 on base"));
 
     PushOneCommit.Result other = createChange();
     // Drafts on other changes aren't returned.
@@ -577,7 +626,7 @@
     assertThat(ps1Map.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> ps1List = ps1Map.get(FILE_NAME);
     assertThat(ps1List).hasSize(2);
-    assertThat(ps1List.get(0).message).isEqualTo("what happened to this?");
+    assertThat(ps1List.get(0).message).isEqualTo("why is this removed?");
     assertThat(ps1List.get(0).side).isEqualTo(Side.PARENT);
     assertThat(ps1List.get(1).message).isEqualTo("nit: trailing whitespace");
     assertThat(ps1List.get(1).side).isNull();
@@ -589,8 +638,8 @@
     assertThat(ps2Map.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> ps2List = ps2Map.get(FILE_NAME);
     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(0).message).isEqualTo("line comment 1 on base");
+    assertThat(ps2List.get(1).message).isEqualTo("line comment 2 on base");
     assertThat(ps2List.get(2).message).isEqualTo("join lines");
     assertThat(ps2List.get(3).message).isEqualTo("typo: content");
 
@@ -615,16 +664,16 @@
                 + url
                 + "#/c/"
                 + c
-                + "/1/a.txt@a2\n"
-                + "PS1, Line 2: \n"
-                + "what happened to this?\n"
+                + "/1/a.txt@a1\n"
+                + "PS1, Line 1: old\n"
+                + "why is this removed?\n"
                 + "\n"
                 + "\n"
                 + url
                 + "#/c/"
                 + c
                 + "/1/a.txt@1\n"
-                + "PS1, Line 1: ew\n"
+                + "PS1, Line 1: new \n"
                 + "nit: trailing whitespace\n"
                 + "\n"
                 + "\n"
@@ -638,23 +687,23 @@
                 + "#/c/"
                 + c
                 + "/2/a.txt@a1\n"
-                + "PS2, Line 1: \n"
-                + "comment 1 on base\n"
+                + "PS2, Line 1: old\n"
+                + "line comment 1 on base\n"
                 + "\n"
                 + "\n"
                 + url
                 + "#/c/"
                 + c
                 + "/2/a.txt@a2\n"
-                + "PS2, Line 2: \n"
-                + "comment 2 on base\n"
+                + "PS2, Line 2: content\n"
+                + "line comment 2 on base\n"
                 + "\n"
                 + "\n"
                 + url
                 + "#/c/"
                 + c
                 + "/2/a.txt@1\n"
-                + "PS2, Line 1: ew\n"
+                + "PS2, Line 1: new \n"
                 + "join lines\n"
                 + "\n"
                 + "\n"
@@ -662,7 +711,7 @@
                 + "#/c/"
                 + c
                 + "/2/a.txt@2\n"
-                + "PS2, Line 2: nten\n"
+                + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
                 + "\n"
                 + "\n");
@@ -739,6 +788,280 @@
     }
   }
 
+  @Test
+  public void deleteCommentCannotBeAppliedByUser() throws Exception {
+    PushOneCommit.Result result = createChange();
+    CommentInput targetComment = addComment(result.getChangeId(), "My password: abc123");
+
+    Map<String, List<CommentInfo>> commentsMap =
+        getPublishedComments(result.getChangeId(), result.getCommit().name());
+
+    assertThat(commentsMap).hasSize(1);
+    assertThat(commentsMap.get(FILE_NAME)).hasSize(1);
+
+    String uuid = commentsMap.get(targetComment.path).get(0).id;
+    DeleteCommentInput input = new DeleteCommentInput("contains confidential information");
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input);
+  }
+
+  @Test
+  public void deleteCommentByRewritingCommitHistory() throws Exception {
+    // Creates the following commit history on the meta branch of the test change. Then tries to
+    // delete the comments one by one, which will rewrite most of the commits on the 'meta' branch.
+    // Commits will be rewritten N times for N added comments. After each deletion, the meta branch
+    // should keep its previous state except that the target comment's message should be updated.
+
+    // 1st commit: Create PS1.
+    PushOneCommit.Result result1 = createChange(SUBJECT, "a.txt", "a");
+    Change.Id id = result1.getChange().getId();
+    String changeId = result1.getChangeId();
+    String ps1 = result1.getCommit().name();
+
+    // 2nd commit: Add (c1) to PS1.
+    CommentInput c1 = newComment("a.txt", "comment 1");
+    addComments(changeId, ps1, c1);
+
+    // 3rd commit: Add (c2, c3) to PS1.
+    CommentInput c2 = newComment("a.txt", "comment 2");
+    CommentInput c3 = newComment("a.txt", "comment 3");
+    addComments(changeId, ps1, c2, c3);
+
+    // 4th commit: Add (c4) to PS1.
+    CommentInput c4 = newComment("a.txt", "comment 4");
+    addComments(changeId, ps1, c4);
+
+    // 5th commit: Create PS2.
+    PushOneCommit.Result result2 = amendChange(changeId, "refs/for/master", "b.txt", "b");
+    String ps2 = result2.getCommit().name();
+
+    // 6th commit: Add (c5) to PS1.
+    CommentInput c5 = newComment("a.txt", "comment 5");
+    addComments(changeId, ps1, c5);
+
+    // 7th commit: Add (c6) to PS2.
+    CommentInput c6 = newComment("b.txt", "comment 6");
+    addComments(changeId, ps2, c6);
+
+    // 8th commit: Create PS3.
+    PushOneCommit.Result result3 = amendChange(changeId);
+    String ps3 = result3.getCommit().name();
+
+    // 9th commit: Create PS4.
+    PushOneCommit.Result result4 = amendChange(changeId, "refs/for/master", "c.txt", "c");
+    String ps4 = result4.getCommit().name();
+
+    // 10th commit: Add (c7, c8) to PS4.
+    CommentInput c7 = newComment("c.txt", "comment 7");
+    CommentInput c8 = newComment("b.txt", "comment 8");
+    addComments(changeId, ps4, c7, c8);
+
+    // 11th commit: Add (c9) to PS2.
+    CommentInput c9 = newComment("b.txt", "comment 9");
+    addComments(changeId, ps2, c9);
+
+    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
+    assertThat(commentsBeforeDelete).hasSize(9);
+    // PS1 has comments [c1, c2, c3, c4, c5].
+    assertThat(getRevisionComments(changeId, ps1)).hasSize(5);
+    // PS2 has comments [c6, c9].
+    assertThat(getRevisionComments(changeId, ps2)).hasSize(2);
+    // PS3 has no comment.
+    assertThat(getRevisionComments(changeId, ps3)).hasSize(0);
+    // PS4 has comments [c7, c8].
+    assertThat(getRevisionComments(changeId, ps4)).hasSize(2);
+
+    setApiUser(admin);
+    for (int i = 0; i < commentsBeforeDelete.size(); i++) {
+      List<RevCommit> commitsBeforeDelete = new ArrayList<>();
+      if (notesMigration.commitChangeWrites()) {
+        commitsBeforeDelete = getCommits(id);
+      }
+
+      CommentInfo comment = commentsBeforeDelete.get(i);
+      String uuid = comment.id;
+      int patchSet = comment.patchSet;
+      // 'oldComment' has some fields unset compared with 'comment'.
+      CommentInfo oldComment = gApi.changes().id(changeId).revision(patchSet).comment(uuid).get();
+
+      DeleteCommentInput input = new DeleteCommentInput("delete comment " + uuid);
+      CommentInfo updatedComment =
+          gApi.changes().id(changeId).revision(patchSet).comment(uuid).delete(input);
+
+      String expectedMsg =
+          String.format("Comment removed by: %s; Reason: %s", admin.fullName, input.reason);
+      assertThat(updatedComment.message).isEqualTo(expectedMsg);
+      oldComment.message = expectedMsg;
+      assertThat(updatedComment).isEqualTo(oldComment);
+
+      // Check the NoteDb state after the deletion.
+      if (notesMigration.commitChangeWrites()) {
+        assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
+      }
+
+      comment.message = expectedMsg;
+      commentsBeforeDelete.set(i, comment);
+      List<CommentInfo> commentsAfterDelete = getChangeSortedComments(changeId);
+      assertThat(commentsAfterDelete).isEqualTo(commentsBeforeDelete);
+    }
+
+    // Make sure that comments can still be added correctly.
+    CommentInput c10 = newComment("a.txt", "comment 10");
+    CommentInput c11 = newComment("b.txt", "comment 11");
+    CommentInput c12 = newComment("a.txt", "comment 12");
+    CommentInput c13 = newComment("c.txt", "comment 13");
+    addComments(changeId, ps1, c10);
+    addComments(changeId, ps2, c11);
+    addComments(changeId, ps3, c12);
+    addComments(changeId, ps4, c13);
+
+    assertThat(getChangeSortedComments(changeId)).hasSize(13);
+    assertThat(getRevisionComments(changeId, ps1)).hasSize(6);
+    assertThat(getRevisionComments(changeId, ps2)).hasSize(3);
+    assertThat(getRevisionComments(changeId, ps3)).hasSize(1);
+    assertThat(getRevisionComments(changeId, ps4)).hasSize(3);
+  }
+
+  @Test
+  public void deleteOneCommentMultipleTimes() throws Exception {
+    PushOneCommit.Result result = createChange();
+    Change.Id id = result.getChange().getId();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput c1 = newComment(FILE_NAME, "comment 1");
+    CommentInput c2 = newComment(FILE_NAME, "comment 2");
+    CommentInput c3 = newComment(FILE_NAME, "comment 3");
+    addComments(changeId, ps1, c1);
+    addComments(changeId, ps1, c2);
+    addComments(changeId, ps1, c3);
+
+    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
+    assertThat(commentsBeforeDelete).hasSize(3);
+    Optional<CommentInfo> targetComment =
+        commentsBeforeDelete.stream().filter(c -> c.message.equals("comment 2")).findFirst();
+    assertThat(targetComment).isPresent();
+    String uuid = targetComment.get().id;
+    CommentInfo oldComment = gApi.changes().id(changeId).revision(ps1).comment(uuid).get();
+
+    List<RevCommit> commitsBeforeDelete = new ArrayList<>();
+    if (notesMigration.commitChangeWrites()) {
+      commitsBeforeDelete = getCommits(id);
+    }
+
+    setApiUser(admin);
+    for (int i = 0; i < 3; i++) {
+      DeleteCommentInput input = new DeleteCommentInput("delete comment 2, iteration: " + i);
+      gApi.changes().id(changeId).revision(ps1).comment(uuid).delete(input);
+    }
+
+    CommentInfo updatedComment = gApi.changes().id(changeId).revision(ps1).comment(uuid).get();
+    String expectedMsg =
+        String.format(
+            "Comment removed by: %s; Reason: %s", admin.fullName, "delete comment 2, iteration: 2");
+    assertThat(updatedComment.message).isEqualTo(expectedMsg);
+    oldComment.message = expectedMsg;
+    assertThat(updatedComment).isEqualTo(oldComment);
+
+    if (notesMigration.commitChangeWrites()) {
+      assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
+    }
+    assertThat(getChangeSortedComments(changeId)).hasSize(3);
+  }
+
+  private List<CommentInfo> getChangeSortedComments(String changeId) throws Exception {
+    List<CommentInfo> comments = new ArrayList<>();
+    Map<String, List<CommentInfo>> commentsMap = getPublishedComments(changeId);
+    for (Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
+      for (CommentInfo c : e.getValue()) {
+        c.path = e.getKey(); // Set the comment's path field.
+        comments.add(c);
+      }
+    }
+    comments.sort(Comparator.comparing(c -> c.id));
+    return comments;
+  }
+
+  private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
+    return getPublishedComments(changeId, revId).values().stream()
+        .flatMap(List::stream)
+        .collect(toList());
+  }
+
+  private CommentInput addComment(String changeId, String message) throws Exception {
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = newComment(FILE_NAME, Side.REVISION, 0, message, false);
+    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
+    gApi.changes().id(changeId).current().review(input);
+    return comment;
+  }
+
+  private void addComments(String changeId, String revision, CommentInput... commentInputs)
+      throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+    gApi.changes().id(changeId).revision(revision).review(input);
+  }
+
+  private List<RevCommit> getCommits(Change.Id changeId) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      Ref metaRef = repo.exactRef(RefNames.changeMetaRef(changeId));
+      revWalk.markStart(revWalk.parseCommit(metaRef.getObjectId()));
+      return Lists.newArrayList(revWalk);
+    }
+  }
+
+  /**
+   * All the commits, which contain the target comment before, should still contain the comment with
+   * the updated message. All the other metas of the commits should be exactly the same.
+   */
+  private void assertMetaBranchCommitsAfterRewriting(
+      List<RevCommit> beforeDelete,
+      Change.Id changeId,
+      String targetCommentUuid,
+      String expectedMessage)
+      throws Exception {
+    List<RevCommit> afterDelete = getCommits(changeId);
+    assertThat(afterDelete).hasSize(beforeDelete.size());
+
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectReader reader = repo.newObjectReader()) {
+      for (int i = 0; i < beforeDelete.size(); i++) {
+        RevCommit commitBefore = beforeDelete.get(i);
+        RevCommit commitAfter = afterDelete.get(i);
+
+        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapBefore =
+            DeleteCommentRewriter.getPublishedComments(
+                noteUtil, changeId, reader, NoteMap.read(reader, commitBefore));
+        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapAfter =
+            DeleteCommentRewriter.getPublishedComments(
+                noteUtil, changeId, reader, NoteMap.read(reader, commitAfter));
+
+        if (commentMapBefore.containsKey(targetCommentUuid)) {
+          assertThat(commentMapAfter).containsKey(targetCommentUuid);
+          com.google.gerrit.reviewdb.client.Comment comment =
+              commentMapAfter.get(targetCommentUuid);
+          assertThat(comment.message).isEqualTo(expectedMessage);
+          comment.message = commentMapBefore.get(targetCommentUuid).message;
+          commentMapAfter.put(targetCommentUuid, comment);
+          assertThat(commentMapAfter).isEqualTo(commentMapBefore);
+        } else {
+          assertThat(commentMapAfter).doesNotContainKey(targetCommentUuid);
+        }
+
+        // Other metas should be exactly the same.
+        assertThat(commitAfter.getFullMessage()).isEqualTo(commitBefore.getFullMessage());
+        assertThat(commitAfter.getCommitterIdent()).isEqualTo(commitBefore.getCommitterIdent());
+        assertThat(commitAfter.getAuthorIdent()).isEqualTo(commitBefore.getAuthorIdent());
+        assertThat(commitAfter.getEncoding()).isEqualTo(commitBefore.getEncoding());
+        assertThat(commitAfter.getEncodingName()).isEqualTo(commitBefore.getEncodingName());
+      }
+    }
+  }
+
   private static String extractComments(String msg) {
     // Extract lines between start "....." and end "-- ".
     Pattern p = Pattern.compile(".*[.]{5}\n+(.*)\\n+-- \n.*", Pattern.DOTALL);
@@ -798,15 +1121,31 @@
     return gApi.changes().id(changeId).revision(revId).comments();
   }
 
+  private List<CommentInfo> getPublishedCommentsAsList(String changeId) throws Exception {
+    return gApi.changes().id(changeId).commentsAsList();
+  }
+
   private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
       throws Exception {
     return gApi.changes().id(changeId).revision(revId).drafts();
   }
 
+  private List<CommentInfo> getDraftCommentsAsList(String changeId) throws Exception {
+    return gApi.changes().id(changeId).draftsAsList();
+  }
+
+  private Map<String, List<CommentInfo>> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).comments();
+  }
+
   private CommentInfo getDraftComment(String changeId, String revId, String uuid) throws Exception {
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
 
+  private static CommentInput newComment(String file, String message) {
+    return newComment(file, Side.REVISION, 0, message, false);
+  }
+
   private static CommentInput newComment(
       String path, Side side, int line, String message, Boolean unresolved) {
     CommentInput c = new CommentInput();
@@ -824,25 +1163,46 @@
     return populate(d, path, side, null, line, message, false);
   }
 
+  private DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
+    DraftInput d = new DraftInput();
+    return populate(d, path, side, null, range.startLine, range, message, false);
+  }
+
   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, false);
   }
 
+  private static Comment.Range range(int line, int startCharacter, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = line;
+    range.startCharacter = startCharacter;
+    range.endLine = line;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
   private static <C extends Comment> C populate(
       C c, String path, Side side, Integer parent, int line, String message, Boolean unresolved) {
+    return populate(c, path, side, parent, line, null, message, unresolved);
+  }
+
+  private static <C extends Comment> C populate(
+      C c,
+      String path,
+      Side side,
+      Integer parent,
+      int line,
+      Comment.Range range,
+      String message,
+      Boolean unresolved) {
     c.path = path;
     c.side = side;
     c.parent = parent;
     c.line = line != 0 ? line : null;
     c.message = message;
     c.unresolved = unresolved;
-    if (line != 0) {
-      Comment.Range range = new Comment.Range();
-      range.startLine = line;
-      range.startCharacter = 1;
-      range.endLine = line;
-      range.endCharacter = 5;
+    if (range != null) {
       c.range = range;
     }
     return c;
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 e0346b3..ed64ce0 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
@@ -41,19 +41,20 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountsUpdate;
 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.validators.CommitValidators;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestChanges;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -68,20 +69,17 @@
 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;
 
 @NoHttpd
 public class ConsistencyCheckerIT extends AbstractDaemonTest {
-  @Inject private ChangeControl.GenericFactory changeControlFactory;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
 
   @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;
@@ -92,10 +90,17 @@
 
   @Inject private Sequences sequences;
 
+  @Inject private AccountsUpdate.Server accountsUpdate;
+
   private RevCommit tip;
   private Account.Id adminId;
   private ConsistencyChecker checker;
 
+  private void assumeNoteDbDisabled() {
+    assume().that(notesMigration.readChanges()).isFalse();
+    assume().that(NoteDbMode.get()).isNotEqualTo(NoteDbMode.CHECK);
+  }
+
   @Before
   public void setUp() throws Exception {
     // Ignore client clone of project; repurpose as server-side TestRepository.
@@ -113,47 +118,47 @@
 
   @Test
   public void validMergedChange() throws Exception {
-    ChangeControl ctl = mergeChange(incrementPatchSet(insertChange()));
-    assertNoProblems(ctl, null);
+    ChangeNotes notes = mergeChange(incrementPatchSet(insertChange()));
+    assertNoProblems(notes, null);
   }
 
   @Test
   public void missingOwner() throws Exception {
-    TestAccount owner = accounts.create("missing");
-    ChangeControl ctl = insertChange(owner);
-    db.accounts().deleteKeys(singleton(owner.getId()));
+    TestAccount owner = accountCreator.create("missing");
+    ChangeNotes notes = insertChange(owner);
+    accountsUpdate.create().deleteByKey(owner.getId());
 
-    assertProblems(ctl, null, problem("Missing change owner: " + owner.getId()));
+    assertProblems(notes, null, problem("Missing change owner: " + owner.getId()));
   }
 
   @Test
   public void missingRepo() throws Exception {
     // NoteDb can't have a change without a repo.
-    assume().that(notesMigration.enabled()).isFalse();
+    assumeNoteDbDisabled();
 
-    ChangeControl ctl = insertChange();
-    Project.NameKey name = ctl.getProject().getNameKey();
+    ChangeNotes notes = insertChange();
+    Project.NameKey name = notes.getProjectName();
     ((InMemoryRepositoryManager) repoManager).deleteRepository(name);
-
-    assertProblems(ctl, null, problem("Destination repository not found: " + name));
+    assertThat(checker.check(notes, null).problems())
+        .containsExactly(problem("Destination repository not found: " + name));
   }
 
   @Test
   public void invalidRevision() throws Exception {
     // NoteDb always parses the revision when inserting a patch set, so we can't
     // create an invalid patch set.
-    assume().that(notesMigration.enabled()).isFalse();
+    assumeNoteDbDisabled();
 
-    ChangeControl ctl = insertChange();
+    ChangeNotes notes = insertChange();
     PatchSet ps =
         newPatchSet(
-            ctl.getChange().currentPatchSetId(),
+            notes.getChange().currentPatchSetId(),
             "fooooooooooooooooooooooooooooooooooooooo",
             adminId);
     db.patchSets().update(singleton(ps));
 
     assertProblems(
-        ctl,
+        notes,
         null,
         problem("Invalid revision on patch set 1: fooooooooooooooooooooooooooooooooooooooo"));
   }
@@ -164,11 +169,11 @@
   @Test
   public void patchSetObjectAndRefMissing() throws Exception {
     String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    ChangeControl ctl = insertChange();
-    PatchSet ps = insertMissingPatchSet(ctl, rev);
-    ctl = reload(ctl);
+    ChangeNotes notes = insertChange();
+    PatchSet ps = insertMissingPatchSet(notes, rev);
+    notes = reload(notes);
     assertProblems(
-        ctl,
+        notes,
         null,
         problem("Ref missing: " + ps.getId().toRefName()),
         problem("Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
@@ -177,13 +182,13 @@
   @Test
   public void patchSetObjectAndRefMissingWithFix() throws Exception {
     String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    ChangeControl ctl = insertChange();
-    PatchSet ps = insertMissingPatchSet(ctl, rev);
-    ctl = reload(ctl);
+    ChangeNotes notes = insertChange();
+    PatchSet ps = insertMissingPatchSet(notes, rev);
+    notes = reload(notes);
 
     String refName = ps.getId().toRefName();
     assertProblems(
-        ctl,
+        notes,
         new FixInput(),
         problem("Ref missing: " + refName),
         problem("Object missing: patch set 2: " + rev));
@@ -191,88 +196,91 @@
 
   @Test
   public void patchSetRefMissing() throws Exception {
-    ChangeControl ctl = insertChange();
+    ChangeNotes notes = insertChange();
     testRepo.update(
-        "refs/other/foo",
-        ObjectId.fromString(psUtil.current(db, ctl.getNotes()).getRevision().get()));
-    String refName = ctl.getChange().currentPatchSetId().toRefName();
+        "refs/other/foo", ObjectId.fromString(psUtil.current(db, notes).getRevision().get()));
+    String refName = notes.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
-    assertProblems(ctl, null, problem("Ref missing: " + refName));
+    assertProblems(notes, null, problem("Ref missing: " + refName));
   }
 
   @Test
   public void patchSetRefMissingWithFix() throws Exception {
-    ChangeControl ctl = insertChange();
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
     testRepo.update("refs/other/foo", ObjectId.fromString(rev));
-    String refName = ctl.getChange().currentPatchSetId().toRefName();
+    String refName = notes.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
     assertProblems(
-        ctl, new FixInput(), problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
+        notes, 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 {
-    ChangeControl ctl = insertChange();
-    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
 
     String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    PatchSet ps2 = insertMissingPatchSet(ctl, rev2);
-    ctl = reload(ctl);
+    PatchSet ps2 = insertMissingPatchSet(notes, rev2);
+    notes = reload(notes);
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem("Ref missing: " + ps2.getId().toRefName()),
         problem("Object missing: patch set 2: " + rev2, FIXED, "Deleted patch set"));
 
-    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();
+    notes = reload(notes);
+    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
+    assertThat(psUtil.get(db, notes, ps1.getId())).isNotNull();
+    assertThat(psUtil.get(db, notes, ps2.getId())).isNull();
   }
 
   @Test
   public void patchSetMultipleObjectsMissingWithDeletingPatchSets() throws Exception {
-    ChangeControl ctl = insertChange();
-    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
 
     String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-    PatchSet ps2 = insertMissingPatchSet(ctl, rev2);
+    PatchSet ps2 = insertMissingPatchSet(notes, rev2);
 
-    ctl = incrementPatchSet(reload(ctl));
-    PatchSet ps3 = psUtil.current(db, ctl.getNotes());
+    notes = incrementPatchSet(reload(notes));
+    PatchSet ps3 = psUtil.current(db, notes);
 
     String rev4 = "c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee";
-    PatchSet ps4 = insertMissingPatchSet(ctl, rev4);
-    ctl = reload(ctl);
+    PatchSet ps4 = insertMissingPatchSet(notes, rev4);
+    notes = reload(notes);
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
     assertProblems(
-        ctl,
+        notes,
         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"));
 
-    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();
+    notes = reload(notes);
+    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(3);
+    assertThat(psUtil.get(db, notes, ps1.getId())).isNotNull();
+    assertThat(psUtil.get(db, notes, ps2.getId())).isNull();
+    assertThat(psUtil.get(db, notes, ps3.getId())).isNotNull();
+    assertThat(psUtil.get(db, notes, ps4.getId())).isNull();
   }
 
   @Test
   public void onlyPatchSetObjectMissingWithFix() throws Exception {
     Change c = TestChanges.newChange(project, admin.getId(), sequences.nextChangeId());
+
+    // Set review started, mimicking Schema_153, so tests pass with NoteDbMode.CHECK.
+    c.setReviewStarted(true);
+
     PatchSet.Id psId = c.currentPatchSetId();
     String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     PatchSet ps = newPatchSet(psId, rev, adminId);
@@ -300,13 +308,12 @@
             + 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);
+    ChangeNotes notes = changeNotesFactory.create(db, c.getProject(), c.getId());
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem("Ref missing: " + ps.getId().toRefName()),
         problem(
@@ -314,35 +321,35 @@
             FIX_FAILED,
             "Cannot delete patch set; no patch sets would remain"));
 
-    ctl = reload(ctl);
-    assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(1);
-    assertThat(psUtil.current(db, ctl.getNotes())).isNotNull();
+    notes = reload(notes);
+    assertThat(notes.getChange().currentPatchSetId().get()).isEqualTo(1);
+    assertThat(psUtil.current(db, notes)).isNotNull();
   }
 
   @Test
   public void currentPatchSetMissing() throws Exception {
     // NoteDb can't create a change without a patch set.
-    assume().that(notesMigration.enabled()).isFalse();
+    assumeNoteDbDisabled();
 
-    ChangeControl ctl = insertChange();
-    db.patchSets().deleteKeys(singleton(ctl.getChange().currentPatchSetId()));
-    assertProblems(ctl, null, problem("Current patch set 1 not found"));
+    ChangeNotes notes = insertChange();
+    db.patchSets().deleteKeys(singleton(notes.getChange().currentPatchSetId()));
+    assertProblems(notes, null, problem("Current patch set 1 not found"));
   }
 
   @Test
   public void duplicatePatchSetRevisions() throws Exception {
-    ChangeControl ctl = insertChange();
-    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
     String rev = ps1.getRevision().get();
 
-    ctl = incrementPatchSet(ctl, testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+    notes = incrementPatchSet(notes, testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
-    assertProblems(ctl, null, problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
+    assertProblems(notes, null, problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
   }
 
   @Test
   public void missingDestRef() throws Exception {
-    ChangeControl ctl = insertChange();
+    ChangeNotes notes = insertChange();
 
     String ref = "refs/heads/master";
     // Detach head so we're allowed to delete ref.
@@ -351,16 +358,16 @@
     ru.setForceUpdate(true);
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
 
-    assertProblems(ctl, null, problem("Destination ref not found (may be new branch): " + ref));
+    assertProblems(notes, null, problem("Destination ref not found (may be new branch): " + ref));
   }
 
   @Test
   public void mergedChangeIsNotMerged() throws Exception {
-    ChangeControl ctl = insertChange();
+    ChangeNotes notes = insertChange();
 
     try (BatchUpdate bu = newUpdate(adminId)) {
       bu.addOp(
-          ctl.getId(),
+          notes.getChangeId(),
           new BatchUpdateOp() {
             @Override
             public boolean updateChange(ChangeContext ctx) throws OrmException {
@@ -371,12 +378,12 @@
           });
       bu.execute();
     }
-    ctl = reload(ctl);
+    notes = reload(notes);
 
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
-    ObjectId tip = getDestRef(ctl);
+    String rev = psUtil.current(db, notes).getRevision().get();
+    ObjectId tip = getDestRef(notes);
     assertProblems(
-        ctl,
+        notes,
         null,
         problem(
             "Patch set 1 ("
@@ -389,14 +396,14 @@
 
   @Test
   public void newChangeIsMerged() throws Exception {
-    ChangeControl ctl = insertChange();
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
     testRepo
-        .branch(ctl.getChange().getDest().get())
+        .branch(notes.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
     assertProblems(
-        ctl,
+        notes,
         null,
         problem(
             "Patch set 1 ("
@@ -409,14 +416,14 @@
 
   @Test
   public void newChangeIsMergedWithFix() throws Exception {
-    ChangeControl ctl = insertChange();
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
     testRepo
-        .branch(ctl.getChange().getDest().get())
+        .branch(notes.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
     assertProblems(
-        ctl,
+        notes,
         new FixInput(),
         problem(
             "Patch set 1 ("
@@ -428,38 +435,38 @@
             FIXED,
             "Marked change as merged"));
 
-    ctl = reload(ctl);
-    assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertNoProblems(ctl, null);
+    notes = reload(notes);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertNoProblems(notes, null);
   }
 
   @Test
   public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
-    ChangeControl ctl = insertChange();
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
     testRepo
-        .branch(ctl.getChange().getDest().get())
+        .branch(notes.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
-    ChangeInfo info = gApi.changes().id(ctl.getId().get()).info();
+    ChangeInfo info = gApi.changes().id(notes.getChangeId().get()).info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
-    info = gApi.changes().id(ctl.getId().get()).check(new FixInput());
+    info = gApi.changes().id(notes.getChangeId().get()).check(new FixInput());
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
   @Test
   public void expectedMergedCommitIsLatestPatchSet() throws Exception {
-    ChangeControl ctl = insertChange();
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
     testRepo
-        .branch(ctl.getChange().getDest().get())
+        .branch(notes.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev;
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem(
             "Patch set 1 ("
@@ -471,23 +478,23 @@
             FIXED,
             "Marked change as merged"));
 
-    ctl = reload(ctl);
-    assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
-    assertNoProblems(ctl, null);
+    notes = reload(notes);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertNoProblems(notes, null);
   }
 
   @Test
   public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
-    ChangeControl ctl = insertChange();
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ChangeNotes notes = insertChange();
+    String rev = psUtil.current(db, notes).getRevision().get();
     RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
-    testRepo.branch(ctl.getChange().getDest().get()).update(commit);
+    testRepo.branch(notes.getChange().getDest().get()).update(commit);
 
     FixInput fix = new FixInput();
     RevCommit other = testRepo.commit().message(commit.getFullMessage()).create();
     fix.expectMergedAs = other.name();
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem(
             "Expected merged commit "
@@ -500,9 +507,9 @@
 
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithNoChangeId() throws Exception {
-    ChangeControl ctl = insertChange();
-    String dest = ctl.getChange().getDest().get();
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ChangeNotes notes = insertChange();
+    String dest = notes.getChange().getDest().get();
+    String rev = psUtil.current(db, notes).getRevision().get();
     RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
 
     RevCommit mergedAs =
@@ -511,12 +518,12 @@
     assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).isEmpty();
     testRepo.update(dest, mergedAs);
 
-    assertNoProblems(ctl, null);
+    assertNoProblems(notes, null);
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem(
             "No patch set found for merged commit " + mergedAs.name(),
@@ -527,20 +534,19 @@
             FIXED,
             "Inserted as patch set 2"));
 
-    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());
+    notes = reload(notes);
+    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
 
-    assertNoProblems(ctl, null);
+    assertNoProblems(notes, null);
   }
 
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithChangeId() throws Exception {
-    ChangeControl ctl = insertChange();
-    String dest = ctl.getChange().getDest().get();
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ChangeNotes notes = insertChange();
+    String dest = notes.getChange().getDest().get();
+    String rev = psUtil.current(db, notes).getRevision().get();
     RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
 
     RevCommit mergedAs =
@@ -552,20 +558,20 @@
                     + "\n"
                     + "\n"
                     + "Change-Id: "
-                    + ctl.getChange().getKey().get()
+                    + notes.getChange().getKey().get()
                     + "\n")
             .create();
     testRepo.getRevWalk().parseBody(mergedAs);
     assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
-        .containsExactly(ctl.getChange().getKey().get());
+        .containsExactly(notes.getChange().getKey().get());
     testRepo.update(dest, mergedAs);
 
-    assertNoProblems(ctl, null);
+    assertNoProblems(notes, null);
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem(
             "No patch set found for merged commit " + mergedAs.name(),
@@ -576,30 +582,29 @@
             FIXED,
             "Inserted as patch set 2"));
 
-    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());
+    notes = reload(notes);
+    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(mergedAs.name());
 
-    assertNoProblems(ctl, null);
+    assertNoProblems(notes, null);
   }
 
   @Test
   public void expectedMergedCommitIsOldPatchSetOfSameChange() throws Exception {
-    ChangeControl ctl = insertChange();
-    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
     String rev1 = ps1.getRevision().get();
-    ctl = incrementPatchSet(ctl);
-    PatchSet ps2 = psUtil.current(db, ctl.getNotes());
+    notes = incrementPatchSet(notes);
+    PatchSet ps2 = psUtil.current(db, notes);
     testRepo
-        .branch(ctl.getChange().getDest().get())
+        .branch(notes.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev1)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev1;
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem("No patch set found for merged commit " + rev1, FIXED, "Marked change as merged"),
         problem(
@@ -617,38 +622,37 @@
             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);
+    notes = reload(notes);
+    PatchSet.Id psId3 = new PatchSet.Id(notes.getChangeId(), 3);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId3);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(ps2.getId(), psId3);
+    assertThat(psUtil.get(db, notes, psId3).getRevision().get()).isEqualTo(rev1);
   }
 
   @Test
   public void expectedMergedCommitIsDanglingPatchSetOlderThanCurrent() throws Exception {
-    ChangeControl ctl = insertChange();
-    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
 
     // Create dangling ref so next ID in the database becomes 3.
-    PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
+    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 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());
+    notes = incrementPatchSet(notes);
+    PatchSet ps3 = psUtil.current(db, notes);
     assertThat(ps3.getId().get()).isEqualTo(3);
 
     testRepo
-        .branch(ctl.getChange().getDest().get())
+        .branch(notes.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev2;
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
         problem(
@@ -666,34 +670,34 @@
             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())
+    notes = reload(notes);
+    PatchSet.Id psId4 = new PatchSet.Id(notes.getChangeId(), 4);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId4);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet())
         .containsExactly(ps1.getId(), ps3.getId(), psId4);
-    assertThat(psUtil.get(db, ctl.getNotes(), psId4).getRevision().get()).isEqualTo(rev2);
+    assertThat(psUtil.get(db, notes, psId4).getRevision().get()).isEqualTo(rev2);
   }
 
   @Test
   public void expectedMergedCommitIsDanglingPatchSetNewerThanCurrent() throws Exception {
-    ChangeControl ctl = insertChange();
-    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+    ChangeNotes notes = insertChange();
+    PatchSet ps1 = psUtil.current(db, notes);
 
     // Create dangling ref with no patch set.
-    PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
+    PatchSet.Id psId2 = new PatchSet.Id(notes.getChangeId(), 2);
     RevCommit commit2 = patchSetCommit(psId2);
     String rev2 = commit2.name();
     testRepo.branch(psId2.toRefName()).update(commit2);
 
     testRepo
-        .branch(ctl.getChange().getDest().get())
+        .branch(notes.getChange().getDest().get())
         .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev2)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev2;
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem("No patch set found for merged commit " + rev2, FIXED, "Marked change as merged"),
         problem(
@@ -704,20 +708,19 @@
             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);
+    notes = reload(notes);
+    assertThat(notes.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertThat(psUtil.byChangeAsMap(db, notes).keySet()).containsExactly(ps1.getId(), psId2);
+    assertThat(psUtil.get(db, notes, psId2).getRevision().get()).isEqualTo(rev2);
   }
 
   @Test
   public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
-    ChangeControl ctl = insertChange();
-    String dest = ctl.getChange().getDest().get();
+    ChangeNotes notes = insertChange();
+    String dest = notes.getChange().getDest().get();
     RevCommit parent = testRepo.branch(dest).commit().message("parent").create();
-    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    String rev = psUtil.current(db, notes).getRevision().get();
     RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
     testRepo.branch(dest).update(commit);
 
@@ -732,12 +735,12 @@
     assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).containsExactly(badId);
     testRepo.update(dest, mergedAs);
 
-    assertNoProblems(ctl, null);
+    assertNoProblems(notes, null);
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
     assertProblems(
-        ctl,
+        notes,
         fix,
         problem(
             "Expected merged commit "
@@ -745,30 +748,30 @@
                 + " has Change-Id: "
                 + badId
                 + ", but expected "
-                + ctl.getChange().getKey().get()));
+                + notes.getChange().getKey().get()));
   }
 
   @Test
   public void expectedMergedCommitMatchesMultiplePatchSets() throws Exception {
-    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();
+    ChangeNotes notes1 = insertChange();
+    PatchSet.Id psId1 = psUtil.current(db, notes1).getId();
+    String dest = notes1.getChange().getDest().get();
+    String rev = psUtil.current(db, notes1).getRevision().get();
     RevCommit commit = testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
     testRepo.branch(dest).update(commit);
 
-    ChangeControl ctl2 = insertChange();
-    ctl2 = incrementPatchSet(ctl2, commit);
-    PatchSet.Id psId2 = psUtil.current(db, ctl2.getNotes()).getId();
+    ChangeNotes notes2 = insertChange();
+    notes2 = incrementPatchSet(notes2, commit);
+    PatchSet.Id psId2 = psUtil.current(db, notes2).getId();
 
-    ChangeControl ctl3 = insertChange();
-    ctl3 = incrementPatchSet(ctl3, commit);
-    PatchSet.Id psId3 = psUtil.current(db, ctl3.getNotes()).getId();
+    ChangeNotes notes3 = insertChange();
+    notes3 = incrementPatchSet(notes3, commit);
+    PatchSet.Id psId3 = psUtil.current(db, notes3).getId();
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = commit.name();
     assertProblems(
-        ctl1,
+        notes1,
         fix,
         problem(
             "Multiple patch sets for expected merged commit "
@@ -783,18 +786,18 @@
   }
 
   private BatchUpdate newUpdate(Account.Id owner) {
-    return updateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
+    return batchUpdateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
   }
 
-  private ChangeControl insertChange() throws Exception {
+  private ChangeNotes insertChange() throws Exception {
     return insertChange(admin);
   }
 
-  private ChangeControl insertChange(TestAccount owner) throws Exception {
+  private ChangeNotes insertChange(TestAccount owner) throws Exception {
     return insertChange(owner, "refs/heads/master");
   }
 
-  private ChangeControl insertChange(TestAccount owner, String dest) throws Exception {
+  private ChangeNotes insertChange(TestAccount owner, String dest) throws Exception {
     Change.Id id = new Change.Id(sequences.nextChangeId());
     ChangeInserter ins;
     try (BatchUpdate bu = newUpdate(owner.getId())) {
@@ -802,41 +805,40 @@
       ins =
           changeInserterFactory
               .create(id, commit, dest)
-              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setValidate(false)
               .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));
+    return changeNotesFactory.create(db, project, ins.getChange().getId());
   }
 
-  private PatchSet.Id nextPatchSetId(ChangeControl ctl) throws Exception {
-    return ChangeUtil.nextPatchSetId(testRepo.getRepository(), ctl.getChange().currentPatchSetId());
+  private PatchSet.Id nextPatchSetId(ChangeNotes notes) throws Exception {
+    return ChangeUtil.nextPatchSetId(
+        testRepo.getRepository(), notes.getChange().currentPatchSetId());
   }
 
-  private ChangeControl incrementPatchSet(ChangeControl ctl) throws Exception {
-    return incrementPatchSet(ctl, patchSetCommit(nextPatchSetId(ctl)));
+  private ChangeNotes incrementPatchSet(ChangeNotes notes) throws Exception {
+    return incrementPatchSet(notes, patchSetCommit(nextPatchSetId(notes)));
   }
 
-  private ChangeControl incrementPatchSet(ChangeControl ctl, RevCommit commit) throws Exception {
+  private ChangeNotes incrementPatchSet(ChangeNotes notes, RevCommit commit) throws Exception {
     PatchSetInserter ins;
-    try (BatchUpdate bu = newUpdate(ctl.getChange().getOwner())) {
+    try (BatchUpdate bu = newUpdate(notes.getChange().getOwner())) {
       ins =
           patchSetInserterFactory
-              .create(ctl, nextPatchSetId(ctl), commit)
-              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .create(notes, nextPatchSetId(notes), commit)
+              .setValidate(false)
               .setFireRevisionCreated(false)
               .setNotify(NotifyHandling.NONE);
-      bu.addOp(ctl.getId(), ins).execute();
+      bu.addOp(notes.getChangeId(), ins).execute();
     }
-    return reload(ctl);
+    return reload(notes);
   }
 
-  private ChangeControl reload(ChangeControl ctl) throws Exception {
-    return changeControlFactory.controlFor(
-        db, ctl.getChange().getProject(), ctl.getId(), ctl.getUser());
+  private ChangeNotes reload(ChangeNotes notes) throws Exception {
+    return changeNotesFactory.create(db, notes.getChange().getProject(), notes.getChangeId());
   }
 
   private RevCommit patchSetCommit(PatchSet.Id psId) throws Exception {
@@ -844,12 +846,12 @@
     return testRepo.parseBody(c);
   }
 
-  private PatchSet insertMissingPatchSet(ChangeControl ctl, String rev) throws Exception {
+  private PatchSet insertMissingPatchSet(ChangeNotes notes, 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);
+    Change c = new Change(notes.getChange());
+    PatchSet.Id psId = nextPatchSetId(notes);
     c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
     PatchSet ps = newPatchSet(psId, rev, adminId);
 
@@ -904,23 +906,22 @@
         .create();
   }
 
-  private ObjectId getDestRef(ChangeControl ctl) throws Exception {
-    return testRepo.getRepository().exactRef(ctl.getChange().getDest().get()).getObjectId();
+  private ObjectId getDestRef(ChangeNotes notes) throws Exception {
+    return testRepo.getRepository().exactRef(notes.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();
+  private ChangeNotes mergeChange(ChangeNotes notes) throws Exception {
+    final ObjectId oldId = getDestRef(notes);
+    final ObjectId newId = ObjectId.fromString(psUtil.current(db, notes).getRevision().get());
+    final String dest = notes.getChange().getDest().get();
 
     try (BatchUpdate bu = newUpdate(adminId)) {
       bu.addOp(
-          ctl.getId(),
+          notes.getChangeId(),
           new BatchUpdateOp() {
             @Override
             public void updateRepo(RepoContext ctx) throws IOException {
-              ctx.addRefUpdate(new ReceiveCommand(oldId, newId, dest));
+              ctx.addRefUpdate(oldId, newId, dest);
             }
 
             @Override
@@ -932,7 +933,7 @@
           });
       bu.execute();
     }
-    return reload(ctl);
+    return reload(notes);
   }
 
   private static ProblemInfo problem(String message) {
@@ -949,14 +950,15 @@
   }
 
   private void assertProblems(
-      ChangeControl ctl, @Nullable FixInput fix, ProblemInfo first, ProblemInfo... rest) {
+      ChangeNotes notes, @Nullable FixInput fix, ProblemInfo first, ProblemInfo... rest)
+      throws Exception {
     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();
+    assertThat(checker.check(notes, fix).problems()).containsExactlyElementsIn(expected).inOrder();
   }
 
-  private void assertNoProblems(ChangeControl ctl, @Nullable FixInput fix) {
-    assertThat(checker.check(ctl, fix).problems()).isEmpty();
+  private void assertNoProblems(ChangeNotes notes, @Nullable FixInput fix) throws Exception {
+    assertThat(checker.check(notes, 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 6c06753..f0dbac2 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
@@ -23,17 +23,16 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 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.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -49,6 +48,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
+@NoHttpd
 public class GetRelatedIT extends AbstractDaemonTest {
   private String systemTimeZone;
 
@@ -64,8 +64,6 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private ChangesCollection changes;
 
   @Test
@@ -541,15 +539,12 @@
     assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
   }
 
-  private List<ChangeAndCommit> getRelated(PatchSet.Id ps) throws Exception {
+  private List<RelatedChangeAndCommitInfo> getRelated(PatchSet.Id ps) throws Exception {
     return getRelated(ps.getParentKey(), ps.get());
   }
 
-  private List<ChangeAndCommit> getRelated(Change.Id changeId, int ps) throws Exception {
-    String url = String.format("/changes/%d/revisions/%d/related", changeId.get(), ps);
-    RestResponse r = adminRestSession.get(url);
-    r.assertOK();
-    return newGson().fromJson(r.getReader(), RelatedInfo.class).changes;
+  private List<RelatedChangeAndCommitInfo> getRelated(Change.Id changeId, int ps) throws Exception {
+    return gApi.changes().id(changeId.get()).revision(ps).related().changes;
   }
 
   private RevCommit parseBody(RevCommit c) throws Exception {
@@ -565,9 +560,10 @@
     return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
   }
 
-  private static ChangeAndCommit changeAndCommit(
+  private RelatedChangeAndCommitInfo changeAndCommit(
       PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
-    ChangeAndCommit result = new ChangeAndCommit();
+    RelatedChangeAndCommitInfo result = new RelatedChangeAndCommitInfo();
+    result.project = project.get();
     result._changeNumber = psId.getParentKey().get();
     result.commit = new CommitInfo();
     result.commit.commit = commitId.name();
@@ -577,8 +573,8 @@
     return result;
   }
 
-  private void clearGroups(final PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = updateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
+  private void clearGroups(PatchSet.Id psId) throws Exception {
+    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
       bu.addOp(
           psId.getParentKey(),
           new BatchUpdateOp() {
@@ -586,7 +582,7 @@
             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);
+              ctx.dontBumpLastUpdatedOn();
               return true;
             }
           });
@@ -594,13 +590,15 @@
     }
   }
 
-  private void assertRelated(PatchSet.Id psId, ChangeAndCommit... expected) throws Exception {
-    List<ChangeAndCommit> actual = getRelated(psId);
+  private void assertRelated(PatchSet.Id psId, RelatedChangeAndCommitInfo... expected)
+      throws Exception {
+    List<RelatedChangeAndCommitInfo> actual = getRelated(psId);
     assertThat(actual).named("related to " + psId).hasSize(expected.length);
     for (int i = 0; i < actual.size(); i++) {
       String name = "index " + i + " related to " + psId;
-      ChangeAndCommit a = actual.get(i);
-      ChangeAndCommit e = expected[i];
+      RelatedChangeAndCommitInfo a = actual.get(i);
+      RelatedChangeAndCommitInfo e = expected[i];
+      assertThat(a.project).named("project of " + name).isEqualTo(e.project);
       assertThat(a._changeNumber).named("change ID of " + name).isEqualTo(e._changeNumber);
       // Don't bother checking changeId; assume _changeNumber is sufficient.
       assertThat(a._revisionNumber).named("revision of " + name).isEqualTo(e._revisionNumber);
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 05dc219..cefde21 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
@@ -17,17 +17,33 @@
 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 java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
+import com.google.gerrit.server.patch.IntraLineDiff;
+import com.google.gerrit.server.patch.IntraLineDiffArgs;
+import com.google.gerrit.server.patch.IntraLineDiffKey;
+import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.Text;
 import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
@@ -44,6 +60,10 @@
 
   @Inject private PatchListCache patchListCache;
 
+  @Inject
+  @Named("diff")
+  private Cache<PatchListKey, PatchList> abstractPatchListCache;
+
   @Test
   public void listPatchesAgainstBase() throws Exception {
     commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
@@ -168,6 +188,51 @@
     assertDeleted(FILE_C, entriesReverse.get(1));
   }
 
+  @Test
+  public void harmfulMutationsOfEditsAreNotPossibleForIntraLineDiffArgsAndCachedValue() {
+    String a = "First line\nSecond line\n";
+    String b = "1st line\n2nd line\n";
+    Text aText = new Text(a.getBytes(UTF_8));
+    Text bText = new Text(b.getBytes(UTF_8));
+    Edit inputEdit = new Edit(0, 2, 0, 2);
+    List<Edit> inputEdits = new ArrayList<>(ImmutableList.of(inputEdit));
+    Set<Edit> inputEditsDueToRebase = new HashSet<>(ImmutableSet.of(inputEdit));
+
+    IntraLineDiffKey diffKey =
+        IntraLineDiffKey.create(ObjectId.zeroId(), ObjectId.zeroId(), Whitespace.IGNORE_NONE);
+    IntraLineDiffArgs diffArgs =
+        IntraLineDiffArgs.create(
+            aText,
+            bText,
+            inputEdits,
+            inputEditsDueToRebase,
+            project,
+            ObjectId.zeroId(),
+            "file.txt");
+    IntraLineDiff intraLineDiff = patchListCache.getIntraLineDiff(diffKey, diffArgs);
+
+    Edit outputEdit = Iterables.getOnlyElement(intraLineDiff.getEdits());
+
+    outputEdit.shift(5);
+    inputEdit.shift(7);
+    inputEdits.add(new Edit(43, 47, 50, 51));
+    inputEditsDueToRebase.add(new Edit(53, 57, 60, 61));
+
+    Edit originalEdit = new Edit(0, 2, 0, 2);
+    assertThat(diffArgs.edits()).containsExactly(originalEdit);
+    assertThat(diffArgs.editsDueToRebase()).containsExactly(originalEdit);
+    assertThat(intraLineDiff.getEdits()).containsExactly(originalEdit);
+  }
+
+  @Test
+  public void largeObjectTombstoneGetsCached() {
+    PatchListKey key = PatchListKey.againstDefaultBase(ObjectId.zeroId(), Whitespace.IGNORE_ALL);
+    PatchListCacheImpl.LargeObjectTombstone tombstone =
+        new PatchListCacheImpl.LargeObjectTombstone();
+    abstractPatchListCache.put(key, tombstone);
+    assertThat(abstractPatchListCache.getIfPresent(key)).isSameAs(tombstone);
+  }
+
   private static void assertAdded(String expectedNewName, PatchListEntry e) {
     assertName(expectedNewName, e);
     assertThat(e.getChangeType()).isEqualTo(ChangeType.ADDED);
@@ -198,7 +263,7 @@
   }
 
   private PatchListKey getKey(ObjectId revisionIdA, ObjectId revisionIdB) {
-    return new PatchListKey(revisionIdA, revisionIdB, Whitespace.IGNORE_NONE);
+    return PatchListKey.againstCommit(revisionIdA, revisionIdB, Whitespace.IGNORE_NONE);
   }
 
   private ObjectId getCurrentRevisionId(String changeId) throws Exception {
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 75bdf4d..49588e7 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
@@ -25,14 +25,11 @@
 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.ChangeInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.testutil.ConfigSuite;
 import java.util.EnumSet;
-import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -155,119 +152,6 @@
   }
 
   @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).isEmpty();
-      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).isEmpty();
-    }
-  }
-
-  @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 topicChaining() throws Exception {
     RevCommit initialHead = getRemoteHead();
     // Create two independent commits and push.
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
index d6ad269..6f4bdab 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
@@ -25,7 +25,9 @@
 import com.google.gerrit.server.mail.receive.MailMessage;
 import java.util.HashMap;
 import org.joda.time.DateTime;
+import org.junit.Ignore;
 
+@Ignore
 public class AbstractMailIT extends AbstractDaemonTest {
 
   protected MailMessage.Builder messageBuilderWithDefaultFields() {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
new file mode 100644
index 0000000..a94a63d
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -0,0 +1,2662 @@
+// 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.acceptance.server.mail;
+
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.api.changes.NotifyHandling.ALL;
+import static com.google.gerrit.extensions.api.changes.NotifyHandling.NONE;
+import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER;
+import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER_REVIEWERS;
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
+import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.ENABLED;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.ABANDONED_CHANGES;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.ALL_COMMENTS;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.NEW_CHANGES;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.NEW_PATCHSETS;
+import static com.google.gerrit.server.account.WatchConfig.NotifyType.SUBMITTED_CHANGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Truth;
+import com.google.gerrit.acceptance.AbstractNotificationTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.changes.AbandonInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.PostReview;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.project.Util;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ChangeNotificationsIT extends AbstractNotificationTest {
+  /*
+   * Set up for extra standard test accounts and permissions.
+   */
+  private TestAccount other;
+  private TestAccount extraReviewer;
+  private TestAccount extraCcer;
+
+  @Before
+  public void createExtraAccounts() throws Exception {
+    extraReviewer =
+        accountCreator.create("extraReviewer", "extraReviewer@example.com", "extraReviewer");
+    extraCcer = accountCreator.create("extraCcer", "extraCcer@example.com", "extraCcer");
+    other = accountCreator.create("other", "other@example.com", "other");
+  }
+
+  @Before
+  public void grantPermissions() throws Exception {
+    grant(project, "refs/*", Permission.FORGE_COMMITTER, false, REGISTERED_USERS);
+    grant(project, "refs/*", Permission.SUBMIT, false, REGISTERED_USERS);
+    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
+    ProjectConfig cfg = projectCache.get(project).getConfig();
+    Util.allow(cfg, Permission.forLabel("Code-Review"), -2, +2, REGISTERED_USERS, "refs/*");
+  }
+
+  /*
+   * AbandonedSender tests.
+   */
+
+  @Test
+  public void abandonReviewableChangeByOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeByOther() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    abandon(sc.changeId, other);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeByOtherCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    abandon(sc.changeId, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyOwnersReviewers() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS, OWNER);
+    // Self-CC applies *after* need for sending notification is determined.
+    // Since there are no recipients before including the user taking action,
+    // there should no notification sent.
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonReviewableChangeByOtherCcingSelfNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    abandon(sc.changeId, other, CC_ON_OWN_COMMENTS, OWNER);
+    assertThat(sender).sent("abandon", sc).to(sc.owner).cc(other).noOneElse();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonReviewableChangeNotifyNoneCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    abandon(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    abandon(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void abandonWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    abandon(sc.changeId, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void abandonWipChangeNotifyAll() throws Exception {
+    StagedChange sc = stageWipChange();
+    abandon(sc.changeId, sc.owner, ALL);
+    assertThat(sender)
+        .sent("abandon", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ABANDONED_CHANGES)
+        .noOneElse();
+  }
+
+  private void abandon(String changeId, TestAccount by) throws Exception {
+    abandon(changeId, by, ENABLED);
+  }
+
+  private void abandon(String changeId, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    abandon(changeId, by, emailStrategy, null);
+  }
+
+  private void abandon(String changeId, TestAccount by, @Nullable NotifyHandling notify)
+      throws Exception {
+    abandon(changeId, by, ENABLED, notify);
+  }
+
+  private void abandon(
+      String changeId, TestAccount by, EmailStrategy emailStrategy, @Nullable NotifyHandling notify)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    AbandonInput in = new AbandonInput();
+    if (notify != null) {
+      in.notify = notify;
+    }
+    gApi.changes().id(changeId).abandon(in);
+  }
+
+  /*
+   * AddReviewerSender tests.
+   */
+
+  private void addReviewerToReviewableChangeInReviewDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInReviewDbSingly() throws Exception {
+    addReviewerToReviewableChangeInReviewDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInReviewDbBatch() throws Exception {
+    addReviewerToReviewableChangeInReviewDb(batch());
+  }
+
+  private void addReviewerToReviewableChangeInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbSingly() throws Exception {
+    addReviewerToReviewableChangeInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbBatch() throws Exception {
+    addReviewerToReviewableChangeInNoteDb(batch());
+  }
+
+  private void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, null);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.owner, sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDbSingly() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOwnerCcingSelfInNoteDbBatch() throws Exception {
+    addReviewerToReviewableChangeByOwnerCcingSelfInNoteDb(batch());
+  }
+
+  private void addReviewerToReviewableChangeByOtherInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, other, reviewer.email);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.owner, sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOtherInNoteDbSingly() throws Exception {
+    addReviewerToReviewableChangeByOtherInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOtherInNoteDbBatch() throws Exception {
+    addReviewerToReviewableChangeByOtherInNoteDb(batch());
+  }
+
+  private void addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, other, reviewer.email, CC_ON_OWN_COMMENTS, null);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.owner, sc.reviewer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOtherCcingSelfInNoteDbSingly() throws Exception {
+    addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeByOtherCcingSelfInNoteDbBatch() throws Exception {
+    addReviewerToReviewableChangeByOtherCcingSelfInNoteDb(batch());
+  }
+
+  private void addReviewerByEmailToReviewableChangeInReviewDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    String email = "addedbyemail@example.com";
+    StagedChange sc = stageReviewableChange();
+    addReviewer(adder, sc.changeId, sc.owner, email);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerByEmailToReviewableChangeInReviewDbSingly() throws Exception {
+    addReviewerByEmailToReviewableChangeInReviewDb(singly());
+  }
+
+  @Test
+  public void addReviewerByEmailToReviewableChangeInReviewDbBatch() throws Exception {
+    addReviewerByEmailToReviewableChangeInReviewDb(batch());
+  }
+
+  private void addReviewerByEmailToReviewableChangeInNoteDb(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    String email = "addedbyemail@example.com";
+    StagedChange sc = stageReviewableChange();
+    addReviewer(adder, sc.changeId, sc.owner, email);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(email)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerByEmailToReviewableChangeInNoteDbSingly() throws Exception {
+    addReviewerByEmailToReviewableChangeInNoteDb(singly());
+  }
+
+  @Test
+  public void addReviewerByEmailToReviewableChangeInNoteDbBatch() throws Exception {
+    addReviewerByEmailToReviewableChangeInNoteDb(batch());
+  }
+
+  private void addReviewerToWipChange(Adder adder) throws Exception {
+    StagedChange sc = stageWipChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerToWipChangeSingly() throws Exception {
+    addReviewerToWipChange(singly());
+  }
+
+  @Test
+  public void addReviewerToWipChangeBatch() throws Exception {
+    addReviewerToWipChange(batch());
+  }
+
+  private void addReviewerToReviewableWipChange(Adder adder) throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerToReviewableWipChangeSingly() throws Exception {
+    addReviewerToReviewableWipChange(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableWipChangeBatch() throws Exception {
+    addReviewerToReviewableWipChange(batch());
+  }
+
+  private void addReviewerToWipChangeInNoteDbNotifyAll(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, NotifyHandling.ALL);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToWipChangeInNoteDbNotifyAllSingly() throws Exception {
+    addReviewerToWipChangeInNoteDbNotifyAll(singly());
+  }
+
+  @Test
+  public void addReviewerToWipChangeInNoteDbNotifyAllBatch() throws Exception {
+    addReviewerToWipChangeInNoteDbNotifyAll(batch());
+  }
+
+  private void addReviewerToWipChangeInReviewDbNotifyAll(Adder adder) throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, NotifyHandling.ALL);
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToWipChangeInReviewDbNotifyAllSingly() throws Exception {
+    addReviewerToWipChangeInReviewDbNotifyAll(singly());
+  }
+
+  @Test
+  public void addReviewerToWipChangeInReviewDbNotifyAllBatch() throws Exception {
+    addReviewerToWipChangeInReviewDbNotifyAll(batch());
+  }
+
+  private void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(Adder adder)
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, OWNER_REVIEWERS);
+    // TODO(logan): Should CCs be included?
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(reviewer)
+        .cc(sc.reviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewersSingly() throws Exception {
+    addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewersBatch() throws Exception {
+    addReviewerToReviewableChangeInNoteDbNotifyOwnerReviewers(batch());
+  }
+
+  private void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(Adder adder)
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwnerSingly()
+      throws Exception {
+    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwnerBatch()
+      throws Exception {
+    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyOwner(batch());
+  }
+
+  private void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(Adder adder)
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount reviewer = accountCreator.create("added", "added@example.com", "added");
+    addReviewer(adder, sc.changeId, sc.owner, reviewer.email, CC_ON_OWN_COMMENTS, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNoneSingly()
+      throws Exception {
+    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(singly());
+  }
+
+  @Test
+  public void addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNoneBatch()
+      throws Exception {
+    addReviewerToReviewableChangeInNoteDbByOwnerCcingSelfNotifyNone(batch());
+  }
+
+  private interface Adder {
+    void addReviewer(String changeId, String reviewer, @Nullable NotifyHandling notify)
+        throws Exception;
+  }
+
+  private Adder singly() {
+    return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
+      AddReviewerInput in = new AddReviewerInput();
+      in.reviewer = reviewer;
+      if (notify != null) {
+        in.notify = notify;
+      }
+      gApi.changes().id(changeId).addReviewer(in);
+    };
+  }
+
+  private Adder batch() {
+    return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
+      ReviewInput in = ReviewInput.noScore();
+      in.reviewer(reviewer);
+      if (notify != null) {
+        in.notify = notify;
+      }
+      gApi.changes().id(changeId).revision("current").review(in);
+    };
+  }
+
+  private void addReviewer(Adder adder, String changeId, TestAccount by, String reviewer)
+      throws Exception {
+    addReviewer(adder, changeId, by, reviewer, ENABLED, null);
+  }
+
+  private void addReviewer(
+      Adder adder, String changeId, TestAccount by, String reviewer, NotifyHandling notify)
+      throws Exception {
+    addReviewer(adder, changeId, by, reviewer, ENABLED, notify);
+  }
+
+  private void addReviewer(
+      Adder adder,
+      String changeId,
+      TestAccount by,
+      String reviewer,
+      EmailStrategy emailStrategy,
+      @Nullable NotifyHandling notify)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    adder.addReviewer(changeId, reviewer, notify);
+  }
+
+  /*
+   * CommentSender tests.
+   */
+
+  @Test
+  public void commentOnReviewableChangeByOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, ENABLED);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByReviewer() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.reviewer, sc.changeId, ENABLED);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByReviewerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.reviewer, sc.changeId, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOther() throws Exception {
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    StagedChange sc = stageReviewableChange();
+    review(other, sc.changeId, ENABLED);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOtherCcingSelf() throws Exception {
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    StagedChange sc = stageReviewableChange();
+    review(other, sc.changeId, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerNotifyOwnerReviewers() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, ENABLED, OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, ENABLED, OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerCcingSelfNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    review(sc.owner, sc.changeId, ENABLED, OWNER);
+    assertThat(sender).notSent(); // TODO(logan): Why not send to owner?
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    review(sc.owner, sc.changeId, ENABLED, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnReviewableChangeByOwnerCcingSelfNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    review(sc.owner, sc.changeId, ENABLED, NONE);
+    assertThat(sender).notSent(); // TODO(logan): Why not send to owner?
+  }
+
+  @Test
+  public void commentOnReviewableChangeByBot() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount bot = sc.testAccount("bot");
+    review(bot, sc.changeId, ENABLED, null, "autogenerated:bot");
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnWipChangeByOwner() throws Exception {
+    StagedChange sc = stageWipChange();
+    review(sc.owner, sc.changeId, ENABLED);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnWipChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageWipChange();
+    review(sc.owner, sc.changeId, CC_ON_OWN_COMMENTS);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnWipChangeByOwnerNotifyAll() throws Exception {
+    StagedChange sc = stageWipChange();
+    review(sc.owner, sc.changeId, ENABLED, ALL);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnWipChangeByBot() throws Exception {
+    StagedChange sc = stageWipChange();
+    TestAccount bot = sc.testAccount("bot");
+    review(bot, sc.changeId, ENABLED, null, "autogenerated:tag");
+    assertThat(sender).sent("comment", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableWipChangeByBot() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    TestAccount bot = sc.testAccount("bot");
+    review(bot, sc.changeId, ENABLED, null, "autogenerated:tag");
+    assertThat(sender).sent("comment", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableWipChangeByBotNotifyAll() throws Exception {
+    StagedChange sc = stageWipChange();
+    TestAccount bot = sc.testAccount("bot");
+    review(bot, sc.changeId, ENABLED, ALL, "tag");
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void commentOnReviewableWipChangeByOwner() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    review(sc.owner, sc.changeId, ENABLED);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void noCommentAndSetWorkInProgress() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentAndSetWorkInProgress() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(true);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void commentOnWipChangeAndStartReview() throws Exception {
+    StagedChange sc = stageWipChange();
+    ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(false);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void addReviewerOnWipChangeAndStartReview() throws Exception {
+    StagedChange sc = stageWipChange();
+    ReviewInput in = ReviewInput.noScore().reviewer(other.email).setWorkInProgress(false);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void startReviewMessageNotRepeated() throws Exception {
+    // TODO(logan): Remove this test check once PolyGerrit workaround is rolled back.
+    StagedChange sc = stageWipChange();
+    ReviewInput in =
+        ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    Truth.assertThat(sender.getMessages()).isNotEmpty();
+    String body = sender.getMessages().get(0).body();
+    int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
+    Truth.assertThat(idx).isAtLeast(0);
+    Truth.assertThat(body.indexOf(PostReview.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
+  }
+
+  private void review(TestAccount account, String changeId, EmailStrategy strategy)
+      throws Exception {
+    review(account, changeId, strategy, null);
+  }
+
+  private void review(
+      TestAccount account, String changeId, EmailStrategy strategy, @Nullable NotifyHandling notify)
+      throws Exception {
+    review(account, changeId, strategy, notify, null);
+  }
+
+  private void review(
+      TestAccount account,
+      String changeId,
+      EmailStrategy strategy,
+      @Nullable NotifyHandling notify,
+      @Nullable String tag)
+      throws Exception {
+    setEmailStrategy(account, strategy);
+    ReviewInput in = ReviewInput.recommend();
+    in.notify = notify;
+    in.tag = tag;
+    gApi.changes().id(changeId).revision("current").review(in);
+  }
+
+  /*
+   * CreateChangeSender tests.
+   */
+
+  @Test
+  public void createReviewableChange() throws Exception {
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    assertThat(sender)
+        .sent("newchange", spc)
+        .to(spc.watchingProjectOwner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void createWipChange() throws Exception {
+    stagePreChange("refs/for/master%wip");
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createWipChangeWithWorkInProgressByDefaultForProject() throws Exception {
+    setWorkInProgressByDefault(project, InheritableBoolean.TRUE);
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    Truth.assertThat(gApi.changes().id(spc.changeId).get().workInProgress).isTrue();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createWipChangeWithWorkInProgressByDefaultForUser() throws Exception {
+    // Make sure owner user is created
+    StagedChange sc = stageReviewableChange();
+    // All was cleaned already
+    assertThat(sender).notSent();
+
+    // Toggle workInProgress flag for owner
+    GeneralPreferencesInfo prefs = gApi.accounts().id(sc.owner.id.get()).getPreferences();
+    prefs.workInProgressByDefault = true;
+    gApi.accounts().id(sc.owner.id.get()).setPreferences(prefs);
+
+    // Create another change without notification that should be wip
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    Truth.assertThat(gApi.changes().id(spc.changeId).get().workInProgress).isTrue();
+    assertThat(sender).notSent();
+
+    // Clean up workInProgressByDefault by owner
+    prefs = gApi.accounts().id(sc.owner.id.get()).getPreferences();
+    Truth.assertThat(prefs.workInProgressByDefault).isTrue();
+    prefs.workInProgressByDefault = false;
+    gApi.accounts().id(sc.owner.id.get()).setPreferences(prefs);
+  }
+
+  @Test
+  public void createReviewableChangeWithNotifyOwnerReviewers() throws Exception {
+    stagePreChange("refs/for/master%notify=OWNER_REVIEWERS");
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createReviewableChangeWithNotifyOwner() throws Exception {
+    stagePreChange("refs/for/master%notify=OWNER");
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createReviewableChangeWithNotifyNone() throws Exception {
+    stagePreChange("refs/for/master%notify=OWNER");
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void createWipChangeWithNotifyAll() throws Exception {
+    StagedPreChange spc = stagePreChange("refs/for/master%wip,notify=ALL");
+    assertThat(sender)
+        .sent("newchange", spc)
+        .to(spc.watchingProjectOwner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void createReviewableChangeWithReviewersAndCcs() throws Exception {
+    // TODO(logan): Support reviewers/CCs-by-email via push option.
+    StagedPreChange spc =
+        stagePreChange(
+            "refs/for/master",
+            users -> ImmutableList.of("r=" + users.reviewer.username, "cc=" + users.ccer.username));
+    assertThat(sender)
+        .sent("newchange", spc)
+        .to(spc.reviewer, spc.watchingProjectOwner)
+        .cc(spc.ccer)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  /*
+   * DeleteReviewerSender tests.
+   */
+
+  @Test
+  public void deleteReviewerFromReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(sc.owner, extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByAdmin() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setApiUser(admin);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(sc.owner, extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByAdminCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setEmailStrategy(admin, EmailStrategy.CC_ON_OWN_COMMENTS);
+    setApiUser(admin);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(sc.owner, extraReviewer)
+        .cc(admin, extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteCcerFromReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraCcer);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(extraCcer)
+        .cc(sc.reviewer, sc.ccer, extraReviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeNotifyOwnerReviewers() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByOwnerCcingSelfNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
+    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
+    assertThat(sender).sent("deleteReviewer", sc).to(sc.owner, extraReviewer).noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    removeReviewer(sc, extraReviewer, NotifyHandling.NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableChangeByOwnerCcingSelfNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    setEmailStrategy(sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
+    removeReviewer(sc, extraReviewer, NotifyHandling.NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChangeWithExtraReviewer();
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromWipChange() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerFromWipChangeNotifyAll() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraReviewer, NotifyHandling.ALL);
+    assertThat(sender)
+        .sent("deleteReviewer", sc)
+        .to(extraReviewer)
+        .cc(extraCcer, sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerWithApprovalFromWipChange() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    removeReviewer(sc, extraReviewer);
+    assertThat(sender).sent("deleteReviewer", sc).to(extraReviewer).noOneElse();
+  }
+
+  @Test
+  public void deleteReviewerWithApprovalFromWipChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    removeReviewer(sc, extraReviewer, NotifyHandling.OWNER);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteReviewerByEmailFromWipChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    gApi.changes().id(sc.changeId).reviewer(sc.reviewerByEmail).remove();
+    assertThat(sender).notSent();
+  }
+
+  private void recommend(StagedChange sc, TestAccount by) throws Exception {
+    setApiUser(by);
+    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.recommend());
+  }
+
+  private interface Stager {
+    StagedChange stage() throws Exception;
+  }
+
+  private StagedChange stageChangeWithExtraReviewer(Stager stager) throws Exception {
+    StagedChange sc = stager.stage();
+    ReviewInput in =
+        ReviewInput.noScore()
+            .reviewer(extraReviewer.email)
+            .reviewer(extraCcer.email, ReviewerState.CC, false);
+    setApiUser(extraReviewer);
+    gApi.changes().id(sc.changeId).revision("current").review(in);
+    return sc;
+  }
+
+  private StagedChange stageReviewableChangeWithExtraReviewer() throws Exception {
+    return stageChangeWithExtraReviewer(this::stageReviewableChange);
+  }
+
+  private StagedChange stageReviewableWipChangeWithExtraReviewer() throws Exception {
+    return stageChangeWithExtraReviewer(this::stageReviewableWipChange);
+  }
+
+  private StagedChange stageWipChangeWithExtraReviewer() throws Exception {
+    return stageChangeWithExtraReviewer(this::stageWipChange);
+  }
+
+  private void removeReviewer(StagedChange sc, TestAccount account) throws Exception {
+    sender.clear();
+    gApi.changes().id(sc.changeId).reviewer(account.email).remove();
+  }
+
+  private void removeReviewer(StagedChange sc, TestAccount account, NotifyHandling notify)
+      throws Exception {
+    sender.clear();
+    DeleteReviewerInput in = new DeleteReviewerInput();
+    in.notify = notify;
+    gApi.changes().id(sc.changeId).reviewer(account.email).remove(in);
+  }
+
+  /*
+   * DeleteVoteSender tests.
+   */
+
+  @Test
+  public void deleteVoteFromReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeWithSelfCc() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeByAdmin() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(admin);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeByAdminCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setEmailStrategy(admin, EmailStrategy.CC_ON_OWN_COMMENTS);
+    setApiUser(admin);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyOwnerReviewers() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyOwnerReviewersWithSelfCc() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer, NotifyHandling.OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(admin);
+    deleteVote(sc, extraReviewer, NotifyHandling.OWNER);
+    assertThat(sender).sent("deleteVote", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer, NotifyHandling.NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableChangeNotifyNoneWithSelfCc() throws Exception {
+    StagedChange sc = stageReviewableChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer, NotifyHandling.NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void deleteVoteFromReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void deleteVoteFromWipChange() throws Exception {
+    StagedChange sc = stageWipChangeWithExtraReviewer();
+    recommend(sc, extraReviewer);
+    setApiUser(sc.owner);
+    deleteVote(sc, extraReviewer);
+    assertThat(sender)
+        .sent("deleteVote", sc)
+        .cc(sc.reviewer, sc.ccer, extraReviewer, extraCcer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  private void deleteVote(StagedChange sc, TestAccount account) throws Exception {
+    sender.clear();
+    gApi.changes().id(sc.changeId).reviewer(account.email).deleteVote("Code-Review");
+  }
+
+  private void deleteVote(StagedChange sc, TestAccount account, NotifyHandling notify)
+      throws Exception {
+    sender.clear();
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = "Code-Review";
+    in.notify = notify;
+    gApi.changes().id(sc.changeId).reviewer(account.email).deleteVote(in);
+  }
+
+  /*
+   * MergedSender tests.
+   */
+
+  @Test
+  public void mergeByOwner() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("merged", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, sc.owner, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByReviewer() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, sc.reviewer);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByReviewerCcingSelf() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, sc.reviewer, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByOtherNotifyOwnerReviewers() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, other, OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void mergeByOtherNotifyOwner() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, other, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void mergeByOtherCcingSelfNotifyOwner() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    setEmailStrategy(other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    merge(sc.changeId, other, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void mergeByOtherNotifyNone() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, other, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void mergeByOtherCcingSelfNotifyNone() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    setEmailStrategy(other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    merge(sc.changeId, other, NONE);
+    assertThat(sender).notSent();
+  }
+
+  private void merge(String changeId, TestAccount by) throws Exception {
+    merge(changeId, by, ENABLED);
+  }
+
+  private void merge(String changeId, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    gApi.changes().id(changeId).revision("current").submit();
+  }
+
+  private void merge(String changeId, TestAccount by, NotifyHandling notify) throws Exception {
+    merge(changeId, by, ENABLED, notify);
+  }
+
+  private void merge(
+      String changeId, TestAccount by, EmailStrategy emailStrategy, NotifyHandling notify)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    SubmitInput in = new SubmitInput();
+    in.notify = notify;
+    gApi.changes().id(changeId).revision("current").submit(in);
+  }
+
+  private StagedChange stageChangeReadyForMerge() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setApiUser(sc.reviewer);
+    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
+    sender.clear();
+    return sc;
+  }
+
+  /*
+   * ReplacePatchSetSender tests.
+   */
+
+  @Test
+  public void newPatchSetByOwnerOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOwnerOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer, other)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This email shouldn't come from the owner.
+        .to(sc.reviewer, sc.ccer, other)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer, other)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer, sc.ccer, other)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeNotifyOwnerReviewersInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeNotifyOwnerReviewersInReviewDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer, sc.ccer)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInNoteDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInReviewDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER_REVIEWERS", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER", other);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=OWNER", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    // TODO(logan): This email shouldn't come from the owner, and that's why
+    // no email is currently sent (owner isn't CCing self).
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=NONE", other);
+    // TODO(logan): This email shouldn't come from the owner, and that's why
+    // no email is currently sent (owner isn't CCing self).
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetByOtherOnReviewableChangeOwnerSelfCcNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%notify=NONE", other, EmailStrategy.CC_ON_OWN_COMMENTS);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetByOwnerOnReviewableChangeToWip() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    pushTo(sc, "refs/for/master%wip", sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%wip", sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeNotifyAllInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%wip,notify=ALL", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeNotifyAllInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%wip,notify=ALL", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeToReadyInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%ready", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeToReadyInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%ready", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void newPatchSetOnReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    pushTo(sc, "refs/for/master%wip", sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnReviewableChangeAddingReviewerInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, newReviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnReviewableChangeAddingReviewerInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer, newReviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeAddingReviewer() throws Exception {
+    StagedChange sc = stageWipChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%r=" + newReviewer.username, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeAddingReviewerNotifyAllInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%notify=ALL,r=" + newReviewer.username, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, newReviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeAddingReviewerNotifyAllInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    TestAccount newReviewer = sc.testAccount("newReviewer");
+    pushTo(sc, "refs/for/master%notify=ALL,r=" + newReviewer.username, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer, newReviewer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeSettingReadyInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%ready", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void newPatchSetOnWipChangeSettingReadyInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    pushTo(sc, "refs/for/master%ready", sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+    assertThat(sender).notSent();
+  }
+
+  private void pushTo(StagedChange sc, String ref, TestAccount by) throws Exception {
+    pushTo(sc, ref, by, ENABLED);
+  }
+
+  private void pushTo(StagedChange sc, String ref, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    pushFactory.create(db, by.getIdent(), sc.repo, sc.changeId).to(ref).assertOkStatus();
+  }
+
+  @Test
+  public void editCommitMessageEditByOwnerOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageEditByOwnerOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, sc.owner);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageEditByOtherOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageEditByOtherOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer, other)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer, other)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewersInNoteDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER_REVIEWERS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewersInReviewDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER_REVIEWERS);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner, sc.reviewer, sc.ccer).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInNoteDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER_REVIEWERS, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer)
+        .cc(sc.ccer, other)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwnerReviewersInReviewDb()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER_REVIEWERS, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer)
+        .cc(other)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyOwner() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, OWNER, CC_ON_OWN_COMMENTS);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner).cc(other).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, NONE);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnReviewableChangeOwnerSelfCcNotifyNone() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    editCommitMessage(sc, other, NONE, CC_ON_OWN_COMMENTS);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void editCommitMessageOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, other);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageByOtherOnWipChangeSelfCc() throws Exception {
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, other, CC_ON_OWN_COMMENTS);
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner).cc(other).noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageOnWipChangeNotifyAllInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, sc.owner, ALL);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  @Test
+  public void editCommitMessageOnWipChangeNotifyAllInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageWipChange();
+    editCommitMessage(sc, sc.owner, ALL);
+    assertThat(sender)
+        .sent("newpatchset", sc)
+        .to(sc.reviewer, sc.ccer)
+        .bcc(sc.starrer)
+        .bcc(NEW_PATCHSETS)
+        .noOneElse();
+  }
+
+  private void editCommitMessage(StagedChange sc, TestAccount by) throws Exception {
+    editCommitMessage(sc, by, null, ENABLED);
+  }
+
+  private void editCommitMessage(StagedChange sc, TestAccount by, @Nullable NotifyHandling notify)
+      throws Exception {
+    editCommitMessage(sc, by, notify, ENABLED);
+  }
+
+  private void editCommitMessage(StagedChange sc, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    editCommitMessage(sc, by, null, emailStrategy);
+  }
+
+  private void editCommitMessage(
+      StagedChange sc, TestAccount by, @Nullable NotifyHandling notify, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    CommitInfo commit = gApi.changes().id(sc.changeId).revision("current").commit(false);
+    CommitMessageInput in = new CommitMessageInput();
+    in.message = "update\n" + commit.message;
+    in.notify = notify;
+    gApi.changes().id(sc.changeId).setMessage(in);
+  }
+
+  /*
+   * RestoredSender tests.
+   */
+
+  @Test
+  public void restoreReviewableChange() throws Exception {
+    StagedChange sc = stageAbandonedReviewableChange();
+    restore(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("restore", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreReviewableWipChange() throws Exception {
+    StagedChange sc = stageAbandonedReviewableWipChange();
+    restore(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("restore", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreWipChange() throws Exception {
+    StagedChange sc = stageAbandonedWipChange();
+    restore(sc.changeId, sc.owner);
+    assertThat(sender)
+        .sent("restore", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreReviewableChangeByAdmin() throws Exception {
+    StagedChange sc = stageAbandonedReviewableChange();
+    restore(sc.changeId, admin);
+    assertThat(sender)
+        .sent("restore", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageAbandonedReviewableChange();
+    restore(sc.changeId, sc.owner, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("restore", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void restoreReviewableChangeByAdminCcingSelf() throws Exception {
+    StagedChange sc = stageAbandonedReviewableChange();
+    restore(sc.changeId, admin, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("restore", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  private void restore(String changeId, TestAccount by) throws Exception {
+    restore(changeId, by, ENABLED);
+  }
+
+  private void restore(String changeId, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    gApi.changes().id(changeId).restore();
+  }
+
+  /*
+   * RevertedSender tests.
+   */
+
+  @Test
+  public void revertChangeByOwnerInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageChange();
+    revert(sc, sc.owner);
+
+    // email for the newly created revert change
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+
+    // email for the change that is reverted
+    assertThat(sender)
+        .sent("revert", sc)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void revertChangeByOwnerInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageChange();
+    revert(sc, sc.owner);
+
+    // email for the newly created revert change
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .cc(sc.ccer)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+
+    // email for the change that is reverted
+    assertThat(sender)
+        .sent("revert", sc)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void revertChangeByOwnerCcingSelfInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageChange();
+    revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
+
+    // email for the newly created revert change
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
+        .cc(sc.owner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+
+    // email for the change that is reverted
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void revertChangeByOwnerCcingSelfInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageChange();
+    revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
+
+    // email for the newly created revert change
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .cc(sc.owner, sc.ccer)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+
+    // email for the change that is reverted
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void revertChangeByOtherInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageChange();
+    revert(sc, other);
+
+    // email for the newly created revert change
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+
+    // email for the change that is reverted
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void revertChangeByOtherInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageChange();
+    revert(sc, other);
+
+    // email for the newly created revert change
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .cc(sc.ccer)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+
+    // email for the change that is reverted
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void revertChangeByOtherCcingSelfInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageChange();
+    revert(sc, other, CC_ON_OWN_COMMENTS);
+
+    // email for the newly created revert change
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.owner, sc.reviewer, sc.ccer, sc.watchingProjectOwner, admin)
+        .cc(other)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+
+    // email for the change that is reverted
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(sc.owner)
+        .cc(other, sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void revertChangeByOtherCcingSelfInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageChange();
+    revert(sc, other, CC_ON_OWN_COMMENTS);
+
+    // email for the newly created revert change
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .cc(sc.ccer, other)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
+
+    // email for the change that is reverted
+    assertThat(sender)
+        .sent("revert", sc)
+        .to(sc.owner)
+        .cc(other, sc.reviewer, sc.ccer, admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  private StagedChange stageChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    setApiUser(admin);
+    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(sc.changeId).revision("current").submit();
+    sender.clear();
+    return sc;
+  }
+
+  private void revert(StagedChange sc, TestAccount by) throws Exception {
+    revert(sc, by, ENABLED);
+  }
+
+  private void revert(StagedChange sc, TestAccount by, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    gApi.changes().id(sc.changeId).revert();
+  }
+
+  /*
+   * SetAssigneeSender tests.
+   */
+
+  @Test
+  public void setAssigneeOnReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeOnReviewableChangeByOwnerCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.assignee, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.owner)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeOnReviewableChangeByAdmin() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    assign(sc, admin, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeOnReviewableChangeByAdminCcingSelf() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    assign(sc, admin, sc.assignee, CC_ON_OWN_COMMENTS);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(admin)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeToSelfOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.owner);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeToSelfOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void changeAssigneeOnReviewableChange() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    TestAccount other = accountCreator.create("other", "other@example.com", "other");
+    assign(sc, sc.owner, other);
+    sender.clear();
+    assign(sc, sc.owner, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void changeAssigneeToSelfOnReviewableChangeInNoteDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.assignee);
+    sender.clear();
+    assign(sc, sc.owner, sc.owner);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .noOneElse();
+  }
+
+  @Test
+  public void changeAssigneeToSelfOnReviewableChangeInReviewDb() throws Exception {
+    assume().that(notesMigration.readChanges()).isFalse();
+    StagedChange sc = stageReviewableChange();
+    assign(sc, sc.owner, sc.assignee);
+    sender.clear();
+    assign(sc, sc.owner, sc.owner);
+    assertThat(sender).notSent();
+  }
+
+  @Test
+  public void setAssigneeOnReviewableWipChange() throws Exception {
+    StagedChange sc = stageReviewableWipChange();
+    assign(sc, sc.owner, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  @Test
+  public void setAssigneeOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    assign(sc, sc.owner, sc.assignee);
+    assertThat(sender)
+        .sent("setassignee", sc)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail) // TODO(logan): This is probably not intended!
+        .to(sc.assignee)
+        .noOneElse();
+  }
+
+  private void assign(StagedChange sc, TestAccount by, TestAccount to) throws Exception {
+    assign(sc, by, to, ENABLED);
+  }
+
+  private void assign(StagedChange sc, TestAccount by, TestAccount to, EmailStrategy emailStrategy)
+      throws Exception {
+    setEmailStrategy(by, emailStrategy);
+    setApiUser(by);
+    AssigneeInput in = new AssigneeInput();
+    in.assignee = to.email;
+    gApi.changes().id(sc.changeId).setAssignee(in);
+  }
+
+  /*
+   * Start review and WIP tests.
+   */
+
+  @Test
+  public void startReviewOnWipChange() throws Exception {
+    StagedChange sc = stageWipChange();
+    startReview(sc);
+    assertThat(sender)
+        .sent("comment", sc)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void startReviewOnWipChangeCcingSelf() throws Exception {
+    StagedChange sc = stageWipChange();
+    setEmailStrategy(sc.owner, CC_ON_OWN_COMMENTS);
+    startReview(sc);
+    assertThat(sender)
+        .sent("comment", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(sc.reviewerByEmail, sc.ccerByEmail)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+  }
+
+  @Test
+  public void setWorkInProgress() throws Exception {
+    StagedChange sc = stageReviewableChange();
+    gApi.changes().id(sc.changeId).setWorkInProgress();
+    assertThat(sender).notSent();
+  }
+
+  private void startReview(StagedChange sc) throws Exception {
+    setApiUser(sc.owner);
+    gApi.changes().id(sc.changeId).setReadyForReview();
+    // PolyGerrit current immediately follows up with a review.
+    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.noScore());
+  }
+
+  private void setWorkInProgressByDefault(Project.NameKey p, InheritableBoolean v)
+      throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.workInProgressByDefault = v;
+    gApi.projects().name(p.get()).config(input);
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
new file mode 100644
index 0000000..d0b7f15d
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.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.acceptance.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Field;
+import org.junit.After;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class EmailValidatorIT extends AbstractDaemonTest {
+  private static final String UNSUPPORTED_PREFIX = "#! ";
+
+  @Inject private OutgoingEmailValidator validator;
+
+  @BeforeClass
+  public static void setUpClass() throws Exception {
+    // Reset before first use, in case other tests have already run in this JVM.
+    resetDomainValidator();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    resetDomainValidator();
+  }
+
+  private static void resetDomainValidator() throws Exception {
+    Class<?> c = Class.forName("org.apache.commons.validator.routines.DomainValidator");
+    Field f = c.getDeclaredField("inUse");
+    f.setAccessible(true);
+    f.setBoolean(c, false);
+  }
+
+  @Test
+  @GerritConfig(name = "sendemail.allowTLD", value = "example")
+  public void testCustomTopLevelDomain() throws Exception {
+    assertThat(validator.isValid("foo@bar.local")).isFalse();
+    assertThat(validator.isValid("foo@bar.example")).isTrue();
+    assertThat(validator.isValid("foo@example")).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "sendemail.allowTLD", value = "a")
+  public void testCustomTopLevelDomainOneCharacter() throws Exception {
+    assertThat(validator.isValid("foo@bar.local")).isFalse();
+    assertThat(validator.isValid("foo@bar.a")).isTrue();
+    assertThat(validator.isValid("foo@a")).isTrue();
+  }
+
+  @Test
+  public void validateTopLevelDomains() throws Exception {
+    try (InputStream in = this.getClass().getResourceAsStream("tlds-alpha-by-domain.txt")) {
+      if (in == null) {
+        throw new Exception("TLD list not found");
+      }
+      BufferedReader r = new BufferedReader(new InputStreamReader(in, UTF_8));
+      String tld;
+      while ((tld = r.readLine()) != null) {
+        if (tld.startsWith("# ") || tld.startsWith("XN--")) {
+          // Ignore comments and non-latin domains
+          continue;
+        }
+        if (tld.startsWith(UNSUPPORTED_PREFIX)) {
+          String test = "test@example." + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
+          assertWithMessage("expected invalid TLD \"" + test + "\"")
+              .that(validator.isValid(test))
+              .isFalse();
+        } else {
+          String test = "test@example." + tld.toLowerCase();
+          assertWithMessage("failed to validate TLD \"" + test + "\"")
+              .that(validator.isValid(test))
+              .isTrue();
+        }
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
index f995316..f25223c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/MailIT.java
@@ -72,7 +72,7 @@
   }
 
   @Test
-  public void delete() throws Exception {
+  public void doesNotDeleteMessageNotMarkedForDeletion() throws Exception {
     GreenMailUser user = mockPop3Server.setUser(USERNAME, USERNAME, PASSWORD);
     user.deliver(createSimpleMessage());
     assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
@@ -80,6 +80,13 @@
     mailReceiver.handleEmails(false);
     // Check that the message is still present
     assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
+  }
+
+  @Test
+  public void deletesMessageMarkedForDeletion() throws Exception {
+    GreenMailUser user = mockPop3Server.setUser(USERNAME, USERNAME, PASSWORD);
+    user.deliver(createSimpleMessage());
+    assertThat(mockPop3Server.getReceivedMessages().length).isEqualTo(1);
     // Mark the message for deletion
     mailReceiver.requestDeletion(mockPop3Server.getReceivedMessages()[0].getMessageID());
     // Let Gerrit handle emails
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
index d314f16..212db28 100644
--- 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
@@ -7,4 +7,7 @@
         "notedb",
         "server",
     ],
+    # TODO(dborowitz): Fix leaks in local disk tests so we can reduce heap size.
+    # http://crbug.com/gerrit/8567
+    vm_args = ["-Xmx1024m"],
 )
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
index ed9cd90..6eaa16d 100644
--- 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
@@ -24,6 +24,7 @@
 import static java.util.concurrent.TimeUnit.DAYS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
@@ -54,6 +55,7 @@
 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.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -73,6 +75,8 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
+import com.google.gerrit.server.patch.PatchListCache;
+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.Util;
@@ -97,6 +101,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
 import org.apache.http.Header;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.junit.TestRepository;
@@ -122,6 +127,10 @@
     // want precise control over when auto-rebuilding happens.
     cfg.setBoolean("index", null, "autoReindexIfStale", false);
 
+    // setNotesMigration tries to keep IDs in sync between ReviewDb and NoteDb, which is behavior
+    // unique to this test. This gets prohibitively slow if we use the default sequence gap.
+    cfg.setInt("noteDb", "changes", "initialSequenceGap", 0);
+
     return cfg;
   }
 
@@ -139,17 +148,17 @@
 
   @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
 
-  @Inject private BatchUpdate.Factory batchUpdateFactory;
-
   @Inject private Sequences seq;
 
   @Inject private ChangeBundleReader bundleReader;
 
   @Inject private PatchSetInfoFactory patchSetInfoFactory;
 
+  @Inject private PatchListCache patchListCache;
+
   @Before
   public void setUp() throws Exception {
-    assume().that(NoteDbMode.readWrite()).isFalse();
+    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
     TestTimeUtil.resetWithClockStep(1, SECONDS);
     setNotesMigration(false, false);
   }
@@ -215,6 +224,7 @@
     Change c = TestChanges.newChange(project, user.getId(), seq.nextChangeId());
     c.setCreatedOn(ts);
     c.setLastUpdatedOn(ts);
+    c.setReviewStarted(true);
     PatchSet ps =
         TestChanges.newPatchSet(
             c.currentPatchSetId(), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", user.getId());
@@ -482,13 +492,13 @@
 
     // TODO(dborowitz): Re-enable these assertions once we fix auto-rebuilding
     // in the BatchUpdate path.
-    //// 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.
+    // 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.
+    // Check that the bundles are equal.
     // ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
     // ChangeBundle actual = ChangeBundle.fromNotes(commentsUtil, notes);
     // ChangeBundle expected = bundleReader.fromReviewDb(getUnwrappedDb(), id);
@@ -754,7 +764,7 @@
     assertThat(ts).isGreaterThan(c.getCreatedOn());
     assertThat(ts).isLessThan(db.patchSets().get(psId).getCreatedOn());
     RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
-    postReview.get().apply(revRsrc, rin, ts);
+    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
 
     checker.rebuildAndCheckChanges(id);
   }
@@ -772,7 +782,7 @@
     Timestamp ts = new Timestamp(c.getCreatedOn().getTime() - 10000);
     RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
     setApiUser(user);
-    postReview.get().apply(revRsrc, rin, ts);
+    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
 
     checker.rebuildAndCheckChanges(id);
   }
@@ -813,32 +823,6 @@
   }
 
   @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();
@@ -891,6 +875,45 @@
   }
 
   @Test
+  public void allTimestampsExceptUpdatedAreEqualDueToBadMigration() throws Exception {
+    // https://bugs.chromium.org/p/gerrit/issues/detail?id=7397
+    PushOneCommit.Result r = createChange();
+    Change c = r.getChange().change();
+    Change.Id id = c.getId();
+    Timestamp ts = TimeUtil.nowTs();
+    Timestamp origUpdated = c.getLastUpdatedOn();
+
+    c.setCreatedOn(ts);
+    assertThat(c.getCreatedOn()).isGreaterThan(c.getLastUpdatedOn());
+    db.changes().update(Collections.singleton(c));
+
+    List<ChangeMessage> cm = db.changeMessages().byChange(id).toList();
+    cm.forEach(m -> m.setWrittenOn(ts));
+    db.changeMessages().update(cm);
+
+    List<PatchSet> ps = db.patchSets().byChange(id).toList();
+    ps.forEach(p -> p.setCreatedOn(ts));
+    db.patchSets().update(ps);
+
+    List<PatchSetApproval> psa = db.patchSetApprovals().byChange(id).toList();
+    psa.forEach(p -> p.setGranted(ts));
+    db.patchSetApprovals().update(psa);
+
+    List<PatchLineComment> plc = db.patchComments().byChange(id).toList();
+    plc.forEach(p -> p.setWrittenOn(ts));
+    db.patchComments().update(plc);
+
+    checker.rebuildAndCheckChanges(id);
+
+    setNotesMigration(true, true);
+    ChangeNotes notes = notesFactory.create(db, project, id);
+    assertThat(notes.getChange().getCreatedOn()).isEqualTo(origUpdated);
+    assertThat(notes.getChange().getLastUpdatedOn()).isAtLeast(origUpdated);
+    assertThat(notes.getPatchSets().get(new PatchSet.Id(id, 1)).getCreatedOn())
+        .isEqualTo(origUpdated);
+  }
+
+  @Test
   public void createWithAutoRebuildingDisabled() throws Exception {
     ReviewDb oldDb = db;
     setNotesMigration(true, true);
@@ -1005,8 +1028,43 @@
       db.rollback();
     }
 
-    exception.expect(NoPatchSetsException.class);
-    checker.rebuildAndCheckChanges(id);
+    try {
+      checker.rebuildAndCheckChanges(id);
+      assert_().fail("expected NoPatchSetsException");
+    } catch (NoPatchSetsException e) {
+      // Expected.
+    }
+
+    Change c = db.changes().get(id);
+    assertThat(c.getNoteDbState()).isNull();
+    checker.assertNoChangeRef(project, id);
+  }
+
+  @Test
+  public void rebuildChangeWithNoEntitiesOtherThanChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    db.changes().beginTransaction(id);
+    try {
+      db.changeMessages().delete(db.changeMessages().byChange(id));
+      db.patchSets().delete(db.patchSets().byChange(id));
+      db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
+      db.patchComments().delete(db.patchComments().byChange(id));
+      db.commit();
+    } finally {
+      db.rollback();
+    }
+
+    try {
+      checker.rebuildAndCheckChanges(id);
+      assert_().fail("expected NoPatchSetsException");
+    } catch (NoPatchSetsException e) {
+      // Expected.
+    }
+
+    Change c = db.changes().get(id);
+    assertThat(c.getNoteDbState()).isNull();
+    checker.assertNoChangeRef(project, id);
   }
 
   @Test
@@ -1297,6 +1355,93 @@
     assertThat(getMetaRef(project, refName)).isNull();
   }
 
+  @Test
+  public void autoRebuildMissingRefWriteOnly() throws Exception {
+    setNotesMigration(true, false);
+    testAutoRebuildMissingRef();
+  }
+
+  @Test
+  public void autoRebuildMissingRefReadWrite() throws Exception {
+    setNotesMigration(true, true);
+    testAutoRebuildMissingRef();
+  }
+
+  private void testAutoRebuildMissingRef() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    assertChangeUpToDate(true, id);
+    notesFactory.createChecked(db, project, id);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate ru = repo.updateRef(RefNames.changeMetaRef(id));
+      ru.setForceUpdate(true);
+      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertChangeUpToDate(false, id);
+
+    notesFactory.createChecked(db, project, id);
+    assertChangeUpToDate(true, id);
+  }
+
+  @Test
+  public void missingPatchSetCommitOkForCommentsNotOnParentSide() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    putDraft(user, id, 1, "draft comment", null, Side.REVISION);
+    putComment(user, id, 1, "published comment", null, Side.REVISION);
+
+    ReviewDb db = getUnwrappedDb();
+    PatchSet ps = db.patchSets().get(new PatchSet.Id(id, 1));
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    db.patchSets().update(Collections.singleton(ps));
+
+    try {
+      patchListCache.getOldId(db.changes().get(id), ps, null);
+      assert_().fail("Expected PatchListNotAvailableException");
+    } catch (PatchListNotAvailableException e) {
+      // Expected.
+    }
+
+    checker.rebuildAndCheckChanges(id);
+  }
+
+  @Test
+  public void missingPatchSetCommitOmitsCommentsOnParentSide() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    CommentInfo draftInfo = putDraft(user, id, 1, "draft comment", null, Side.PARENT);
+    putComment(user, id, 1, "published comment", null, Side.PARENT);
+    CommentInfo commentInfo =
+        gApi.changes().id(id.get()).comments().values().stream()
+            .flatMap(List::stream)
+            .findFirst()
+            .get();
+
+    ReviewDb db = getUnwrappedDb();
+    PatchSet ps = db.patchSets().get(new PatchSet.Id(id, 1));
+    ps.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    db.patchSets().update(Collections.singleton(ps));
+
+    try {
+      patchListCache.getOldId(db.changes().get(id), ps, null);
+      assert_().fail("Expected PatchListNotAvailableException");
+    } catch (PatchListNotAvailableException e) {
+      // Expected.
+    }
+
+    checker.rebuildAndCheckChange(
+        id,
+        Stream.of(draftInfo.id, commentInfo.id)
+            .sorted()
+            .map(c -> id + ",1," + PushOneCommit.FILE_NAME + "," + c)
+            .collect(
+                joining(", ", "PatchLineComment.Key sets differ: [", "] only in A; [] only in B")));
+  }
+
   private void assertChangesReadOnly(RestApiException e) throws Exception {
     Throwable cause = e.getCause();
     assertThat(cause).isInstanceOf(UpdateException.class);
@@ -1320,8 +1465,10 @@
       Change c = getUnwrappedDb().changes().get(id);
       assertThat(c).isNotNull();
       assertThat(c.getNoteDbState()).isNotNull();
-      assertThat(NoteDbChangeState.parse(c).isChangeUpToDate(new RepoRefCache(repo)))
-          .isEqualTo(expected);
+      NoteDbChangeState state = NoteDbChangeState.parse(c);
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+      assertThat(state.isChangeUpToDate(new RepoRefCache(repo))).isEqualTo(expected);
     }
   }
 
@@ -1344,16 +1491,24 @@
     }
   }
 
-  private void putDraft(TestAccount account, Change.Id id, int line, String msg, Boolean unresolved)
+  private CommentInfo putDraft(
+      TestAccount account, Change.Id id, int line, String msg, Boolean unresolved)
+      throws Exception {
+    return putDraft(account, id, line, msg, unresolved, Side.REVISION);
+  }
+
+  private CommentInfo putDraft(
+      TestAccount account, Change.Id id, int line, String msg, Boolean unresolved, Side side)
       throws Exception {
     DraftInput in = new DraftInput();
+    in.side = side;
     in.line = line;
     in.message = msg;
     in.path = PushOneCommit.FILE_NAME;
     in.unresolved = unresolved;
     AcceptanceTestRequestScope.Context old = setApiUser(account);
     try {
-      gApi.changes().id(id.get()).current().createDraft(in);
+      return gApi.changes().id(id.get()).current().createDraft(in).get();
     } finally {
       atrScope.set(old);
     }
@@ -1361,7 +1516,14 @@
 
   private void putComment(TestAccount account, Change.Id id, int line, String msg, String inReplyTo)
       throws Exception {
+    putComment(account, id, line, msg, inReplyTo, Side.REVISION);
+  }
+
+  private void putComment(
+      TestAccount account, Change.Id id, int line, String msg, String inReplyTo, Side side)
+      throws Exception {
     CommentInput in = new CommentInput();
+    in.side = side;
     in.line = line;
     in.message = msg;
     in.inReplyTo = inReplyTo;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
new file mode 100644
index 0000000..6291447
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -0,0 +1,323 @@
+// 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.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+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.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class NoteDbOnlyIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    // Avoid spurious timeouts during intentional retries due to overloaded test machines.
+    cfg.setString("noteDb", null, "retryTimeout", Integer.MAX_VALUE + "s");
+    return cfg;
+  }
+
+  @Inject private RetryHelper retryHelper;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+  }
+
+  @Test
+  public void updateChangeFailureRollsBackRefUpdate() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    String master = "refs/heads/master";
+    String backup = "refs/backup/master";
+    ObjectId master1 = getRef(master).get();
+    assertThat(getRef(backup)).isEmpty();
+
+    // Toy op that copies the value of refs/heads/master to refs/backup/master.
+    BatchUpdateOp backupMasterOp =
+        new BatchUpdateOp() {
+          ObjectId newId;
+
+          @Override
+          public void updateRepo(RepoContext ctx) throws IOException {
+            ObjectId oldId = ctx.getRepoView().getRef(backup).orElse(ObjectId.zeroId());
+            newId = ctx.getRepoView().getRef(master).get();
+            ctx.addRefUpdate(oldId, newId, backup);
+          }
+
+          @Override
+          public boolean updateChange(ChangeContext ctx) {
+            ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                .setChangeMessage("Backed up master branch to " + newId.name());
+            return true;
+          }
+        };
+
+    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+      bu.addOp(id, backupMasterOp);
+      bu.execute();
+    }
+
+    // Ensure backupMasterOp worked.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).contains("Backed up master branch to " + master1.name());
+
+    // Advance master by submitting the change.
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().submit();
+    ObjectId master2 = getRef(master).get();
+    assertThat(master2).isNotEqualTo(master1);
+    int msgCount = getMessages(id).size();
+
+    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+      // This time, we attempt to back up master, but we fail during updateChange.
+      bu.addOp(id, backupMasterOp);
+      String msg = "Change is bad";
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws ResourceConflictException {
+              throw new ResourceConflictException(msg);
+            }
+          });
+      try {
+        bu.execute();
+        assert_().fail("expected ResourceConflictException");
+      } catch (ResourceConflictException e) {
+        assertThat(e).hasMessageThat().isEqualTo(msg);
+      }
+    }
+
+    // If updateChange hadn't failed, backup would have been updated to master2.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).hasSize(msgCount);
+  }
+
+  @Test
+  public void retryOnLockFailureWithAtomicUpdates() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    String master = "refs/heads/master";
+    ObjectId initial;
+    try (Repository repo = repoManager.openRepository(project)) {
+      ensureAtomicTransactions(repo);
+      initial = repo.exactRef(master).getObjectId();
+    }
+
+    AtomicInteger updateRepoCalledCount = new AtomicInteger();
+    AtomicInteger updateChangeCalledCount = new AtomicInteger();
+    AtomicInteger afterUpdateReposCalledCount = new AtomicInteger();
+
+    String result =
+        retryHelper.execute(
+            batchUpdateFactory -> {
+              try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+                bu.addOp(
+                    id,
+                    new UpdateRefAndAddMessageOp(updateRepoCalledCount, updateChangeCalledCount));
+                bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
+              }
+              return "Done";
+            });
+
+    assertThat(result).isEqualTo("Done");
+    assertThat(updateRepoCalledCount.get()).isEqualTo(2);
+    assertThat(afterUpdateReposCalledCount.get()).isEqualTo(2);
+    assertThat(updateChangeCalledCount.get()).isEqualTo(2);
+
+    List<String> messages = getMessages(id);
+    assertThat(Iterables.getLast(messages)).isEqualTo(UpdateRefAndAddMessageOp.CHANGE_MESSAGE);
+    assertThat(Collections.frequency(messages, UpdateRefAndAddMessageOp.CHANGE_MESSAGE))
+        .isEqualTo(1);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // Op lost the race, so the other writer's commit happened first. Then op retried and wrote
+      // its commit with the other writer's commit as parent.
+      assertThat(commitMessages(repo, initial, repo.exactRef(master).getObjectId()))
+          .containsExactly(
+              ConcurrentWritingListener.MSG_PREFIX + "1", UpdateRefAndAddMessageOp.COMMIT_MESSAGE)
+          .inOrder();
+    }
+  }
+
+  @Test
+  public void missingChange() throws Exception {
+    Change.Id changeId = new Change.Id(1234567);
+    assertNoSuchChangeException(() -> notesFactory.create(db, project, changeId));
+    assertNoSuchChangeException(() -> notesFactory.createChecked(db, project, changeId));
+  }
+
+  private void assertNoSuchChangeException(Callable<?> callable) throws Exception {
+    try {
+      callable.call();
+      assert_().fail("expected NoSuchChangeException");
+    } catch (NoSuchChangeException e) {
+      // Expected.
+    }
+  }
+
+  private class ConcurrentWritingListener implements BatchUpdateListener {
+    static final String MSG_PREFIX = "Other writer ";
+
+    private final AtomicInteger calledCount;
+
+    private ConcurrentWritingListener(AtomicInteger calledCount) {
+      this.calledCount = calledCount;
+    }
+
+    @Override
+    public void afterUpdateRepos() throws Exception {
+      // Reopen repo and update ref, to simulate a concurrent write in another
+      // thread. Only do this the first time the listener is called.
+      if (calledCount.getAndIncrement() > 0) {
+        return;
+      }
+      try (Repository repo = repoManager.openRepository(project);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        String master = "refs/heads/master";
+        ObjectId oldId = repo.exactRef(master).getObjectId();
+        ObjectId newId = newCommit(rw, ins, oldId, MSG_PREFIX + calledCount.get());
+        ins.flush();
+        RefUpdate ru = repo.updateRef(master);
+        ru.setExpectedOldObjectId(oldId);
+        ru.setNewObjectId(newId);
+        assertThat(ru.update(rw)).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+      }
+    }
+  }
+
+  private class UpdateRefAndAddMessageOp implements BatchUpdateOp {
+    static final String COMMIT_MESSAGE = "A commit";
+    static final String CHANGE_MESSAGE = "A change message";
+
+    private final AtomicInteger updateRepoCalledCount;
+    private final AtomicInteger updateChangeCalledCount;
+
+    private UpdateRefAndAddMessageOp(
+        AtomicInteger updateRepoCalledCount, AtomicInteger updateChangeCalledCount) {
+      this.updateRepoCalledCount = updateRepoCalledCount;
+      this.updateChangeCalledCount = updateChangeCalledCount;
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws Exception {
+      String master = "refs/heads/master";
+      ObjectId oldId = ctx.getRepoView().getRef(master).get();
+      ObjectId newId = newCommit(ctx.getRevWalk(), ctx.getInserter(), oldId, COMMIT_MESSAGE);
+      ctx.addRefUpdate(oldId, newId, master);
+      updateRepoCalledCount.incrementAndGet();
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage(CHANGE_MESSAGE);
+      updateChangeCalledCount.incrementAndGet();
+      return true;
+    }
+  }
+
+  private ObjectId newCommit(RevWalk rw, ObjectInserter ins, ObjectId parent, String msg)
+      throws IOException {
+    PersonIdent ident = serverIdent.get();
+    CommitBuilder cb = new CommitBuilder();
+    cb.setParentId(parent);
+    cb.setTreeId(rw.parseCommit(parent).getTree());
+    cb.setMessage(msg);
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    return ins.insert(Constants.OBJ_COMMIT, cb.build());
+  }
+
+  private BatchUpdate newBatchUpdate(BatchUpdate.Factory buf) {
+    return buf.create(db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs());
+  }
+
+  private Optional<ObjectId> getRef(String name) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return Optional.ofNullable(repo.exactRef(name)).map(Ref::getObjectId);
+    }
+  }
+
+  private List<String> getMessages(Change.Id id) throws Exception {
+    return gApi.changes().id(id.get()).get(MESSAGES).messages.stream()
+        .map(m -> m.message)
+        .collect(toList());
+  }
+
+  private static List<String> commitMessages(
+      Repository repo, ObjectId fromExclusive, ObjectId toInclusive) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      rw.markStart(rw.parseCommit(toInclusive));
+      rw.markUninteresting(rw.parseCommit(fromExclusive));
+      rw.sort(RevSort.REVERSE);
+      rw.setRetainBody(true);
+      return Streams.stream(rw).map(c -> c.getShortMessage()).collect(toList());
+    }
+  }
+
+  private void ensureAtomicTransactions(Repository repo) throws Exception {
+    if (repo instanceof InMemoryRepository) {
+      ((InMemoryRepository) repo).setPerformsAtomicTransactions(true);
+    } else {
+      assertThat(repo.getRefDatabase().performsAtomicTransactions())
+          .named("performsAtomicTransactions on %s", repo)
+          .isTrue();
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
index 183ef8f..01f3d19 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.formatTime;
 import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
@@ -62,8 +63,7 @@
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestTimeUtil;
@@ -97,13 +97,13 @@
   }
 
   @Inject private AllUsersName allUsers;
-  @Inject private BatchUpdate.Factory batchUpdateFactory;
   @Inject private ChangeBundleReader bundleReader;
   @Inject private CommentsUtil commentsUtil;
   @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
-  @Inject private ChangeControl.GenericFactory changeControlFactory;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
   @Inject private ChangeUpdate.Factory updateFactory;
   @Inject private InternalUser.Factory internalUserFactory;
+  @Inject private RetryHelper retryHelper;
 
   private PrimaryStorageMigrator migrator;
 
@@ -124,11 +124,11 @@
         allUsers,
         rebuilderWrapper,
         ensureRebuiltRetryer,
-        changeControlFactory,
+        changeNotesFactory,
         queryProvider,
         updateFactory,
         internalUserFactory,
-        batchUpdateFactory);
+        retryHelper);
   }
 
   @After
@@ -273,7 +273,7 @@
           Throwables.getCausalChain(e).stream()
               .filter(x -> x instanceof OrmRuntimeException)
               .findFirst();
-      assertThat(oe.isPresent()).named("OrmRuntimeException in causal chain of " + e).isTrue();
+      assertThat(oe).named("OrmRuntimeException in causal chain of " + e).isPresent();
       assertThat(oe.get().getMessage()).contains("read-only");
     }
     assertThat(gApi.changes().id(id.get()).get().topic).isNull();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
new file mode 100644
index 0000000..32c556b
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
@@ -0,0 +1,676 @@
+// 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.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.notedb.NoteDbChangeState.NOTE_DB_PRIMARY_STATE;
+import static com.google.gerrit.server.notedb.NotesMigrationState.NOTE_DB;
+import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_NO_SEQUENCE;
+import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY;
+import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY;
+import static com.google.gerrit.server.notedb.NotesMigrationState.REVIEW_DB;
+import static com.google.gerrit.server.notedb.NotesMigrationState.WRITE;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.easymock.EasyMock.createStrictMock;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+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.CommentsUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.notedb.ChangeBundle;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
+import com.google.gerrit.server.notedb.NotesMigrationState;
+import com.google.gerrit.server.notedb.rebuild.MigrationException;
+import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.gerrit.server.notedb.rebuild.NotesMigrationStateListener;
+import com.google.gerrit.server.schema.ReviewDbFactory;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.NoteDbMode;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+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.ObjectLoader;
+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.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@Sandboxed
+@UseLocalDisk
+@NoHttpd
+public class OnlineNoteDbMigrationIT extends AbstractDaemonTest {
+  private static final String INVALID_STATE = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setInt("noteDb", "changes", "sequenceBatchSize", 10);
+    cfg.setInt("noteDb", "changes", "initialSequenceGap", 500);
+    return cfg;
+  }
+
+  // Tests in this class are generally interested in the actual ReviewDb contents, but the shifting
+  // migration state may result in various kinds of wrappers showing up unexpectedly.
+  @Inject @ReviewDbFactory private SchemaFactory<ReviewDb> schemaFactory;
+
+  @Inject private ChangeBundleReader changeBundleReader;
+  @Inject private CommentsUtil commentsUtil;
+  @Inject private DynamicSet<NotesMigrationStateListener> listeners;
+  @Inject private Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
+  @Inject private Sequences sequences;
+  @Inject private SitePaths sitePaths;
+
+  private FileBasedConfig noteDbConfig;
+  private List<RegistrationHandle> addedListeners;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
+    // Unlike in the running server, for tests, we don't stack notedb.config on gerrit.config.
+    noteDbConfig = new FileBasedConfig(sitePaths.notedb_config.toFile(), FS.detect());
+    assertNotesMigrationState(REVIEW_DB, false, false);
+    addedListeners = new ArrayList<>();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (addedListeners != null) {
+      addedListeners.forEach(RegistrationHandle::remove);
+      addedListeners = null;
+    }
+  }
+
+  @Test
+  public void preconditionsFail() throws Exception {
+    List<Change.Id> cs = ImmutableList.of(new Change.Id(1));
+    List<Project.NameKey> ps = ImmutableList.of(new Project.NameKey("p"));
+    assertMigrationException(
+        "Cannot rebuild without noteDb.changes.write=true", b -> b, NoteDbMigrator::rebuild);
+    assertMigrationException(
+        "Cannot combine changes, projects and skipProjects",
+        b -> b.setChanges(cs).setProjects(ps),
+        m -> {});
+    assertMigrationException(
+        "Cannot combine changes, projects and skipProjects",
+        b -> b.setChanges(cs).setSkipProjects(ps),
+        m -> {});
+    assertMigrationException(
+        "Cannot combine changes, projects and skipProjects",
+        b -> b.setProjects(ps).setSkipProjects(ps),
+        m -> {});
+    assertMigrationException(
+        "Cannot set changes or projects or skipProjects during full migration",
+        b -> b.setChanges(cs),
+        NoteDbMigrator::migrate);
+    assertMigrationException(
+        "Cannot set changes or projects or skipProjects during full migration",
+        b -> b.setProjects(ps),
+        NoteDbMigrator::migrate);
+    assertMigrationException(
+        "Cannot set changes or projects or skipProjects during full migration",
+        b -> b.setSkipProjects(ps),
+        NoteDbMigrator::migrate);
+
+    setNotesMigrationState(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
+    assertMigrationException(
+        "Migration has already progressed past the endpoint of the \"trial mode\" state",
+        b -> b.setTrialMode(true),
+        NoteDbMigrator::migrate);
+
+    setNotesMigrationState(READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
+    assertMigrationException(
+        "Cannot force rebuild changes; NoteDb is already the primary storage for some changes",
+        b -> b.setForceRebuild(true),
+        NoteDbMigrator::migrate);
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.changes.initialSequenceGap", value = "-7")
+  public void initialSequenceGapMustBeNonNegative() throws Exception {
+    setNotesMigrationState(READ_WRITE_NO_SEQUENCE);
+    assertMigrationException("Sequence gap must be non-negative: -7", b -> b, m -> {});
+  }
+
+  @Test
+  public void rebuildOneChangeTrialModeAndForceRebuild() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    migrate(b -> b.setTrialMode(true));
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
+
+    ObjectId oldMetaId;
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
+      assertThat(ref).isNotNull();
+      oldMetaId = ref.getObjectId();
+
+      Change c = db.changes().get(id);
+      assertThat(c).isNotNull();
+      NoteDbChangeState state = NoteDbChangeState.parse(c);
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+      assertThat(state.getRefState()).hasValue(RefState.create(oldMetaId, ImmutableMap.of()));
+
+      // Force change to be out of date, and change topic so it will get rebuilt as something other
+      // than oldMetaId.
+      c.setNoteDbState(INVALID_STATE);
+      c.setTopic(name("a-new-topic"));
+      db.changes().update(ImmutableList.of(c));
+    }
+
+    migrate(b -> b.setTrialMode(true));
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
+
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      // Change is out of date, but was not rebuilt without forceRebuild.
+      assertThat(repo.exactRef(RefNames.changeMetaRef(id)).getObjectId()).isEqualTo(oldMetaId);
+      Change c = db.changes().get(id);
+      assertThat(c.getNoteDbState()).isEqualTo(INVALID_STATE);
+    }
+
+    migrate(b -> b.setTrialMode(true).setForceRebuild(true));
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, false, true);
+
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
+      assertThat(ref).isNotNull();
+      ObjectId newMetaId = ref.getObjectId();
+      assertThat(newMetaId).isNotEqualTo(oldMetaId);
+
+      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+      assertThat(state.getRefState()).hasValue(RefState.create(newMetaId, ImmutableMap.of()));
+    }
+  }
+
+  @Test
+  public void autoMigrateTrialMode() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    migrate(b -> b.setAutoMigrate(true).setTrialMode(true).setStopAtStateForTesting(WRITE));
+    assertNotesMigrationState(WRITE, true, true);
+
+    migrate(b -> b);
+    // autoMigrate is still enabled so that we can continue the migration by only unsetting trial.
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, true, true);
+
+    ObjectId metaId;
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
+      assertThat(ref).isNotNull();
+      metaId = ref.getObjectId();
+      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+      assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
+    }
+
+    // Unset trial mode and the next migration runs to completion.
+    noteDbConfig.load();
+    NoteDbMigrator.setTrialMode(noteDbConfig, false);
+    noteDbConfig.save();
+
+    migrate(b -> b);
+    assertNotesMigrationState(NOTE_DB, false, false);
+
+    try (Repository repo = repoManager.openRepository(project);
+        ReviewDb db = schemaFactory.open()) {
+      Ref ref = repo.exactRef(RefNames.changeMetaRef(id));
+      assertThat(ref).isNotNull();
+      assertThat(ref.getObjectId()).isEqualTo(metaId);
+      NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.NOTE_DB);
+    }
+  }
+
+  @Test
+  public void rebuildSubsetOfChanges() throws Exception {
+    setNotesMigrationState(WRITE);
+
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+
+    invalidateNoteDbState(id1, id2);
+    migrate(b -> b.setChanges(ImmutableList.of(id2)), NoteDbMigrator::rebuild);
+    assertNotRebuilt(id1);
+    assertRebuilt(id2);
+  }
+
+  @Test
+  public void rebuildSubsetOfProjects() throws Exception {
+    setNotesMigrationState(WRITE);
+
+    Project.NameKey p2 = createProject("project2");
+    TestRepository<?> tr2 = cloneProject(p2, admin);
+
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = pushFactory.create(db, admin.getIdent(), tr2).to("refs/for/master");
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+
+    invalidateNoteDbState(id1, id2);
+    migrate(b -> b.setProjects(ImmutableList.of(p2)), NoteDbMigrator::rebuild);
+    assertNotRebuilt(id1);
+    assertRebuilt(id2);
+  }
+
+  @Test
+  public void rebuildNonSkippedProjects() throws Exception {
+    setNotesMigrationState(WRITE);
+
+    Project.NameKey p2 = createProject("project2");
+    TestRepository<?> tr2 = cloneProject(p2, admin);
+    Project.NameKey p3 = createProject("project3");
+    TestRepository<?> tr3 = cloneProject(p3, admin);
+
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = pushFactory.create(db, admin.getIdent(), tr2).to("refs/for/master");
+    PushOneCommit.Result r3 = pushFactory.create(db, admin.getIdent(), tr3).to("refs/for/master");
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+    Change.Id id3 = r3.getChange().getId();
+
+    invalidateNoteDbState(id1, id2, id3);
+    migrate(b -> b.setSkipProjects(ImmutableList.of(p3)), NoteDbMigrator::rebuild);
+    assertRebuilt(id1, id2);
+    assertNotRebuilt(id3);
+  }
+
+  private void invalidateNoteDbState(Change.Id... ids) throws OrmException {
+    List<Change> list = new ArrayList<>(ids.length);
+    try (ReviewDb db = schemaFactory.open()) {
+      for (Change.Id id : ids) {
+        Change c = db.changes().get(id);
+        c.setNoteDbState(INVALID_STATE);
+        list.add(c);
+      }
+      db.changes().update(list);
+    }
+  }
+
+  private void assertRebuilt(Change.Id... ids) throws OrmException {
+    try (ReviewDb db = schemaFactory.open()) {
+      for (Change.Id id : ids) {
+        NoteDbChangeState s = NoteDbChangeState.parse(db.changes().get(id));
+        assertThat(s.getChangeMetaId().name()).isNotEqualTo(INVALID_STATE);
+      }
+    }
+  }
+
+  private void assertNotRebuilt(Change.Id... ids) throws OrmException {
+    try (ReviewDb db = schemaFactory.open()) {
+      for (Change.Id id : ids) {
+        NoteDbChangeState s = NoteDbChangeState.parse(db.changes().get(id));
+        assertThat(s.getChangeMetaId().name()).isEqualTo(INVALID_STATE);
+      }
+    }
+  }
+
+  @Test
+  public void enableSequencesNoGap() throws Exception {
+    testEnableSequences(0, 3, "13");
+  }
+
+  @Test
+  public void enableSequencesWithGap() throws Exception {
+    testEnableSequences(-1, 502, "512");
+  }
+
+  private void testEnableSequences(int builderOption, int expectedFirstId, String expectedRefValue)
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    assertThat(id.get()).isEqualTo(1);
+
+    migrate(
+        b ->
+            b.setSequenceGap(builderOption)
+                .setStopAtStateForTesting(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY));
+
+    assertThat(sequences.nextChangeId()).isEqualTo(expectedFirstId);
+    assertThat(sequences.nextChangeId()).isEqualTo(expectedFirstId + 1);
+
+    try (Repository repo = repoManager.openRepository(allProjects);
+        ObjectReader reader = repo.newObjectReader()) {
+      Ref ref = repo.exactRef("refs/sequences/changes");
+      assertThat(ref).isNotNull();
+      ObjectLoader loader = reader.open(ref.getObjectId());
+      assertThat(loader.getType()).isEqualTo(Constants.OBJ_BLOB);
+      // Acquired a block of 10 to serve the first nextChangeId call after migration.
+      assertThat(new String(loader.getCachedBytes(), UTF_8)).isEqualTo(expectedRefValue);
+    }
+
+    try (ReviewDb db = schemaFactory.open()) {
+      // Underlying, unused ReviewDb is still on its own sequence.
+      @SuppressWarnings("deprecation")
+      int nextFromReviewDb = db.nextChangeId();
+      assertThat(nextFromReviewDb).isEqualTo(3);
+    }
+  }
+
+  @Test
+  public void fullMigrationSameThread() throws Exception {
+    testFullMigration(1);
+  }
+
+  @Test
+  public void fullMigrationMultipleThreads() throws Exception {
+    testFullMigration(2);
+  }
+
+  private void testFullMigration(int threads) throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+
+    Set<String> objectFiles = getObjectFiles(project);
+    assertThat(objectFiles).isNotEmpty();
+
+    migrate(b -> b.setThreads(threads));
+
+    assertNotesMigrationState(NOTE_DB, false, false);
+    assertThat(sequences.nextChangeId()).isEqualTo(503);
+    assertThat(getObjectFiles(project)).containsExactlyElementsIn(objectFiles);
+
+    ObjectId oldMetaId = null;
+    int rowVersion = 0;
+    try (ReviewDb db = schemaFactory.open();
+        Repository repo = repoManager.openRepository(project)) {
+      for (Change.Id id : ImmutableList.of(id1, id2)) {
+        String refName = RefNames.changeMetaRef(id);
+        Ref ref = repo.exactRef(refName);
+        assertThat(ref).named(refName).isNotNull();
+
+        Change c = db.changes().get(id);
+        assertThat(c.getTopic()).named("topic of change %s", id).isNull();
+        NoteDbChangeState s = NoteDbChangeState.parse(c);
+        assertThat(s.getPrimaryStorage())
+            .named("primary storage of change %s", id)
+            .isEqualTo(PrimaryStorage.NOTE_DB);
+        assertThat(s.getRefState()).named("ref state of change %s").isEmpty();
+
+        if (id.equals(id1)) {
+          oldMetaId = ref.getObjectId();
+          rowVersion = c.getRowVersion();
+        }
+      }
+    }
+
+    // Do not open a new context, to simulate races with other threads that opened a context earlier
+    // in the migration process; this needs to work.
+    gApi.changes().id(id1.get()).topic(name("a-topic"));
+
+    // Of course, it should also work with a new context.
+    resetCurrentApiUser();
+    gApi.changes().id(id1.get()).topic(name("another-topic"));
+
+    try (ReviewDb db = schemaFactory.open();
+        Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef(RefNames.changeMetaRef(id1)).getObjectId()).isNotEqualTo(oldMetaId);
+
+      Change c = db.changes().get(id1);
+      assertThat(c.getTopic()).isNull();
+      assertThat(c.getRowVersion()).isEqualTo(rowVersion);
+    }
+  }
+
+  @Test
+  public void fullMigrationOneChangeWithNoPatchSets() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+
+    db.changes().beginTransaction(id2);
+    try {
+      db.patchSets().delete(db.patchSets().byChange(id2));
+      db.commit();
+    } finally {
+      db.rollback();
+    }
+
+    migrate(b -> b);
+    assertNotesMigrationState(NOTE_DB, false, false);
+
+    try (ReviewDb db = schemaFactory.open();
+        Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef(RefNames.changeMetaRef(id1))).isNotNull();
+      assertThat(db.changes().get(id1).getNoteDbState()).isEqualTo(NOTE_DB_PRIMARY_STATE);
+
+      // A change with no patch sets is so corrupt that it is completely skipped by the migration
+      // process.
+      assertThat(repo.exactRef(RefNames.changeMetaRef(id2))).isNull();
+      assertThat(db.changes().get(id2).getNoteDbState()).isNull();
+    }
+  }
+
+  @Test
+  public void fullMigrationMissingPatchSetRefs() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate u = repo.updateRef(new PatchSet.Id(id, 1).toRefName());
+      u.setForceUpdate(true);
+      assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+
+    ChangeBundle reviewDbBundle;
+    try (ReviewDb db = schemaFactory.open()) {
+      reviewDbBundle = changeBundleReader.fromReviewDb(db, id);
+    }
+
+    migrate(b -> b);
+    assertNotesMigrationState(NOTE_DB, false, false);
+
+    try (ReviewDb db = schemaFactory.open();
+        Repository repo = repoManager.openRepository(project)) {
+      // Change migrated successfully even though it was missing patch set refs.
+      assertThat(repo.exactRef(RefNames.changeMetaRef(id))).isNotNull();
+      assertThat(db.changes().get(id).getNoteDbState()).isEqualTo(NOTE_DB_PRIMARY_STATE);
+
+      ChangeBundle noteDbBundle =
+          ChangeBundle.fromNotes(commentsUtil, notesFactory.createChecked(db, project, id));
+      assertThat(noteDbBundle.differencesFrom(reviewDbBundle)).isEmpty();
+    }
+  }
+
+  @Test
+  public void autoMigrationConfig() throws Exception {
+    createChange();
+
+    migrate(b -> b.setStopAtStateForTesting(WRITE));
+    assertNotesMigrationState(WRITE, false, false);
+
+    migrate(b -> b.setAutoMigrate(true).setStopAtStateForTesting(READ_WRITE_NO_SEQUENCE));
+    assertNotesMigrationState(READ_WRITE_NO_SEQUENCE, true, false);
+
+    migrate(b -> b);
+    assertNotesMigrationState(NOTE_DB, false, false);
+  }
+
+  @Test
+  public void notesMigrationStateListener() throws Exception {
+    NotesMigrationStateListener listener = createStrictMock(NotesMigrationStateListener.class);
+    listener.preStateChange(REVIEW_DB, WRITE);
+    expectLastCall();
+    listener.preStateChange(WRITE, READ_WRITE_NO_SEQUENCE);
+    expectLastCall();
+    listener.preStateChange(READ_WRITE_NO_SEQUENCE, READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
+    expectLastCall();
+    listener.preStateChange(
+        READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY, READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
+    listener.preStateChange(READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY, NOTE_DB);
+    expectLastCall();
+    replay(listener);
+    addListener(listener);
+
+    createChange();
+    migrate(b -> b);
+    assertNotesMigrationState(NOTE_DB, false, false);
+    verify(listener);
+  }
+
+  @Test
+  public void notesMigrationStateListenerFails() throws Exception {
+    NotesMigrationStateListener listener = createStrictMock(NotesMigrationStateListener.class);
+    listener.preStateChange(REVIEW_DB, WRITE);
+    expectLastCall();
+    listener.preStateChange(WRITE, READ_WRITE_NO_SEQUENCE);
+    IOException listenerException = new IOException("Listener failed");
+    expectLastCall().andThrow(listenerException);
+    replay(listener);
+    addListener(listener);
+
+    createChange();
+    try {
+      migrate(b -> b);
+      assert_().fail("expected IOException");
+    } catch (IOException e) {
+      assertThat(e).isSameAs(listenerException);
+    }
+    assertNotesMigrationState(WRITE, false, false);
+    verify(listener);
+  }
+
+  private void assertNotesMigrationState(
+      NotesMigrationState expected, boolean autoMigrate, boolean trialMode) throws Exception {
+    assertThat(NotesMigrationState.forNotesMigration(notesMigration)).hasValue(expected);
+    noteDbConfig.load();
+    assertThat(NotesMigrationState.forConfig(noteDbConfig)).hasValue(expected);
+    assertThat(NoteDbMigrator.getAutoMigrate(noteDbConfig))
+        .named("noteDb.changes.autoMigrate")
+        .isEqualTo(autoMigrate);
+    assertThat(NoteDbMigrator.getTrialMode(noteDbConfig))
+        .named("noteDb.changes.trial")
+        .isEqualTo(trialMode);
+  }
+
+  private void setNotesMigrationState(NotesMigrationState state) throws Exception {
+    noteDbConfig.load();
+    state.setConfigValues(noteDbConfig);
+    noteDbConfig.save();
+    notesMigration.setFrom(state);
+  }
+
+  @FunctionalInterface
+  interface PrepareBuilder {
+    NoteDbMigrator.Builder prepare(NoteDbMigrator.Builder b) throws Exception;
+  }
+
+  @FunctionalInterface
+  interface RunMigration {
+    void run(NoteDbMigrator m) throws Exception;
+  }
+
+  private void migrate(PrepareBuilder b) throws Exception {
+    migrate(b, NoteDbMigrator::migrate);
+  }
+
+  private void migrate(PrepareBuilder b, RunMigration m) throws Exception {
+    try (NoteDbMigrator migrator = b.prepare(migratorBuilderProvider.get()).build()) {
+      m.run(migrator);
+    }
+  }
+
+  private void assertMigrationException(
+      String expectMessageContains, PrepareBuilder b, RunMigration m) throws Exception {
+    try {
+      migrate(b, m);
+      assert_().fail("expected MigrationException");
+    } catch (MigrationException e) {
+      assertThat(e).hasMessageThat().contains(expectMessageContains);
+    }
+  }
+
+  private void addListener(NotesMigrationStateListener listener) {
+    addedListeners.add(listeners.add(listener));
+  }
+
+  private SortedSet<String> getObjectFiles(Project.NameKey project) throws Exception {
+    SortedSet<String> files = new TreeSet<>();
+    try (Repository repo = repoManager.openRepository(project)) {
+      Files.walkFileTree(
+          ((FileRepository) repo).getObjectDatabase().getDirectory().toPath(),
+          new SimpleFileVisitor<Path>() {
+            @Override
+            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
+              String name = file.getFileName().toString();
+              if (!attrs.isDirectory() && !name.endsWith(".pack") && !name.endsWith(".idx")) {
+                files.add(name);
+              }
+              return FileVisitResult.CONTINUE;
+            }
+          });
+    }
+    return files;
+  }
+}
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 e110942..6a43cc4 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
@@ -15,6 +15,10 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.common.data.LabelFunction.ANY_WITH_BLOCK;
+import static com.google.gerrit.common.data.LabelFunction.MAX_NO_BLOCK;
+import static com.google.gerrit.common.data.LabelFunction.NO_BLOCK;
+import static com.google.gerrit.common.data.LabelFunction.NO_OP;
 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;
@@ -81,7 +85,7 @@
 
   @Test
   public void customLabelNoOp_NegativeVoteNotBlock() throws Exception {
-    label.setFunctionName("NoOp");
+    label.setFunction(NO_OP);
     saveLabelConfig();
     PushOneCommit.Result r = createChange();
     revision(r).review(new ReviewInput().label(label.getName(), -1));
@@ -94,7 +98,7 @@
 
   @Test
   public void customLabelNoBlock_NegativeVoteNotBlock() throws Exception {
-    label.setFunctionName("NoBlock");
+    label.setFunction(NO_BLOCK);
     saveLabelConfig();
     PushOneCommit.Result r = createChange();
     revision(r).review(new ReviewInput().label(label.getName(), -1));
@@ -107,7 +111,7 @@
 
   @Test
   public void customLabelMaxNoBlock_NegativeVoteNotBlock() throws Exception {
-    label.setFunctionName("MaxNoBlock");
+    label.setFunction(MAX_NO_BLOCK);
     saveLabelConfig();
     PushOneCommit.Result r = createChange();
     revision(r).review(new ReviewInput().label(label.getName(), -1));
@@ -120,7 +124,7 @@
 
   @Test
   public void customLabelAnyWithBlock_NegativeVoteBlock() throws Exception {
-    label.setFunctionName("AnyWithBlock");
+    label.setFunction(ANY_WITH_BLOCK);
     saveLabelConfig();
     PushOneCommit.Result r = createChange();
     revision(r).review(new ReviewInput().label(label.getName(), -1));
@@ -134,7 +138,7 @@
 
   @Test
   public void customLabelAnyWithBlock_Addreviewer_ZeroVote() throws Exception {
-    P.setFunctionName("AnyWithBlock");
+    P.setFunction(ANY_WITH_BLOCK);
     saveLabelConfig();
     PushOneCommit.Result r = createChange();
     AddReviewerInput in = new AddReviewerInput();
@@ -169,9 +173,9 @@
 
   @Test
   public void customLabel_DisallowPostSubmit() throws Exception {
-    label.setFunctionName("NoOp");
+    label.setFunction(NO_OP);
     label.setAllowPostSubmit(false);
-    P.setFunctionName("NoOp");
+    P.setFunction(NO_OP);
     saveLabelConfig();
 
     PushOneCommit.Result r = createChange();
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 174fb76..bc82e8d 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
@@ -23,7 +23,6 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
@@ -39,7 +38,6 @@
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.inject.Inject;
-import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
@@ -96,7 +94,7 @@
   }
 
   @Test
-  public void noNotificationForDraftChangesForWatchersInNotifyConfig() throws Exception {
+  public void noNotificationForPrivateChangesForWatchersInNotifyConfig() throws Exception {
     Address addr = new Address("Watcher", "watcher@example.com");
     NotifyConfig nc = new NotifyConfig();
     nc.addEmail(addr);
@@ -108,10 +106,11 @@
     cfg.putNotifyConfig("team", nc);
     saveProjectConfig(project, cfg);
 
+    sender.clear();
     PushOneCommit.Result r =
         pushFactory
-            .create(db, admin.getIdent(), testRepo, "draft change", "a", "a1")
-            .to("refs/for/master%draft");
+            .create(db, admin.getIdent(), testRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
     r.assertOkStatus();
 
     assertThat(sender.getMessages()).isEmpty();
@@ -125,13 +124,14 @@
   }
 
   @Test
-  public void noNotificationForDraftPatchSetsForWatchersInNotifyConfig() throws Exception {
+  public void noNotificationForChangeThatIsTurnedPrivateForWatchersInNotifyConfig()
+      throws Exception {
     Address addr = new Address("Watcher", "watcher@example.com");
     NotifyConfig nc = new NotifyConfig();
     nc.addEmail(addr);
     nc.setName("team");
     nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS, NotifyType.ALL_COMMENTS));
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
 
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     cfg.putNotifyConfig("team", nc);
@@ -148,7 +148,30 @@
     r =
         pushFactory
             .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
-            .to("refs/for/master%draft");
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForWipChangesForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    sender.clear();
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "wip change", "a", "a1")
+            .to("refs/for/master%wip");
     r.assertOkStatus();
 
     assertThat(sender.getMessages()).isEmpty();
@@ -162,11 +185,41 @@
   }
 
   @Test
+  public void noNotificationForChangeThatIsTurnedWipForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    sender.clear();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master%wip");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void watchProject() throws Exception {
     // watch project
     String watchedProject = createProject("watchedProject").get();
     setApiUser(user);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a change to watched project -> should trigger email notification
     setApiUser(admin);
@@ -208,7 +261,7 @@
     watch(watchedProject, "file:a.txt");
 
     // watch other project as user
-    watch(otherWatchedProject, null);
+    watch(otherWatchedProject);
 
     // push a change to watched file -> should trigger email notification for
     // user
@@ -231,9 +284,9 @@
     sender.clear();
 
     // watch project as user2
-    TestAccount user2 = accounts.create("user2", "user2@test.com", "User2");
+    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
     setApiUser(user2);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a change to non-watched file -> should not trigger email
     // notification for user, only for user2
@@ -297,7 +350,7 @@
     setApiUser(user);
 
     // watch the All-Projects project to watch all projects
-    watch(allProjects.get(), null);
+    watch(allProjects.get());
 
     // push a change to any project -> should trigger email notification
     setApiUser(admin);
@@ -348,9 +401,9 @@
     sender.clear();
 
     // watch project as user2
-    TestAccount user2 = accounts.create("user2", "user2@test.com", "User2");
+    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2");
     setApiUser(user2);
-    watch(anyProject, null);
+    watch(anyProject);
 
     // push a change to non-watched file in any project -> should not trigger
     // email notification for user, only for user2
@@ -410,75 +463,11 @@
   }
 
   @Test
-  public void watchProjectNoNotificationForDraftChange() throws Exception {
-    // watch project
-    String watchedProject = createProject("watchedProject").get();
-    setApiUser(user);
-    watch(watchedProject, null);
-
-    // push a draft change to watched project -> should not trigger email notification
-    setApiUser(admin);
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(new Project.NameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(db, admin.getIdent(), watchedRepo, "draft change", "a", "a1")
-            .to("refs/for/master%draft");
-    r.assertOkStatus();
-
-    // assert email notification
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
-  public void watchProjectNotifyOnDraftChange() throws Exception {
-    String watchedProject = createProject("watchedProject").get();
-
-    // create group that can view all drafts
-    GroupInfo groupThatCanViewDrafts = gApi.groups().create("groupThatCanViewDrafts").get();
-    grant(
-        Permission.VIEW_DRAFTS,
-        new Project.NameKey(watchedProject),
-        "refs/*",
-        false,
-        new AccountGroup.UUID(groupThatCanViewDrafts.id));
-
-    // watch project as user that can't view drafts
-    setApiUser(user);
-    watch(watchedProject, null);
-
-    // watch project as user that can view all drafts
-    TestAccount userThatCanViewDrafts =
-        accounts.create("user2", "user2@test.com", "User2", groupThatCanViewDrafts.name);
-    setApiUser(userThatCanViewDrafts);
-    watch(watchedProject, null);
-
-    // push a draft change to watched project -> should trigger email notification for
-    // userThatCanViewDrafts, but not for user
-    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%draft");
-    r.assertOkStatus();
-
-    // assert email notification
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(userThatCanViewDrafts.emailAddress);
-    assertThat(m.body()).contains("Change subject: TRIGGER\n");
-    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
-  }
-
-  @Test
   public void watchProjectNoNotificationForIgnoredChange() throws Exception {
     // watch project
     String watchedProject = createProject("watchedProject").get();
     setApiUser(user);
-    watch(watchedProject, null);
+    watch(watchedProject);
 
     // push a change to watched project
     setApiUser(admin);
@@ -520,9 +509,7 @@
   @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));
+    Account.Id id = accountCreator.create().id;
 
     // Add a project watch so that a watch.config file in the refs/users/ branch is created.
     Map<ProjectWatchKey, Set<NotifyType>> watches = new HashMap<>();
@@ -535,4 +522,70 @@
     watchConfig.deleteAllProjectWatches(id);
     assertThat(watchConfig.getProjectWatches(id)).isEmpty();
   }
+
+  @Test
+  public void watchProjectNoNotificationForPrivateChange() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject);
+
+    // push a private change to watched project -> should not trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNotifyOnPrivateChange() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+
+    // create group that can view all private changes
+    GroupInfo groupThatCanViewPrivateChanges =
+        gApi.groups().create("groupThatCanViewPrivateChanges").get();
+    grant(
+        new Project.NameKey(watchedProject),
+        "refs/*",
+        Permission.VIEW_PRIVATE_CHANGES,
+        false,
+        new AccountGroup.UUID(groupThatCanViewPrivateChanges.id));
+
+    // watch project as user that can't view private changes
+    setApiUser(user);
+    watch(watchedProject);
+
+    // watch project as user that can view all private change
+    TestAccount userThatCanViewPrivateChanges =
+        accountCreator.create(
+            "user2", "user2@test.com", "User2", groupThatCanViewPrivateChanges.name);
+    setApiUser(userThatCanViewPrivateChanges);
+    watch(watchedProject);
+
+    // push a private change to watched project -> should trigger email notification for
+    // userThatCanViewPrivateChanges, but not for user
+    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%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ReflogIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ReflogIT.java
index 31617bf..7b4e2d6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.server.project;
 
 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 com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -26,14 +28,39 @@
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.Util;
+import java.io.File;
 import java.util.List;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
 public class ReflogIT extends AbstractDaemonTest {
   @Test
   @UseLocalDisk
+  public void guessRestApiInReflog() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
+      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
+      if (!log.exists()) {
+        log.getParentFile().mkdirs();
+        assertThat(log.createNewFile()).isTrue();
+      }
+
+      gApi.changes().id(id.get()).topic("foo");
+      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
+      assertThat(last).named("last RefLogEntry").isNotNull();
+      assertThat(last.getComment()).isEqualTo("change.PutTopic");
+    }
+  }
+
+  @Test
+  @UseLocalDisk
   public void reflogUpdatedBySubmittingChange() throws Exception {
     BranchApi branchApi = gApi.projects().name(project.get()).branch("master");
     List<ReflogEntryInfo> reflog = branchApi.reflog();
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
index 3b7783f..0d9422d 100644
--- 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
@@ -32,6 +32,8 @@
     ],
     deps = [
         ":util",
+        "//gerrit-elasticsearch:elasticsearch",
+        "//gerrit-elasticsearch:elasticsearch_test_utils",
         "//lib/commons:compress",
     ],
 )
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 7a80f2e..2b00718 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
@@ -42,6 +42,6 @@
         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");
+    assertThat(u.getMessage()).contains("contains banned commit");
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index 18ad621..839564f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -14,44 +14,33 @@
 
 package com.google.gerrit.acceptance.ssh;
 
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
+
 import com.google.gerrit.elasticsearch.ElasticVersion;
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.inject.Injector;
-import java.util.UUID;
 import org.eclipse.jgit.lib.Config;
 
 public class ElasticIndexIT extends AbstractIndexTests {
 
-  private static Config getConfig(ElasticVersion version) {
-    ElasticNodeInfo elasticNodeInfo;
-    ElasticContainer<?> container = ElasticContainer.createAndStart(version);
-    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-    String indicesPrefix = UUID.randomUUID().toString();
-    Config cfg = new Config();
-    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
-    return cfg;
-  }
-
   @ConfigSuite.Default
-  public static Config elasticsearchV2() {
-    return getConfig(ElasticVersion.V2_4);
-  }
-
-  @ConfigSuite.Config
   public static Config elasticsearchV5() {
     return getConfig(ElasticVersion.V5_6);
   }
 
   @ConfigSuite.Config
   public static Config elasticsearchV6() {
-    return getConfig(ElasticVersion.V6_4);
+    return getConfig(ElasticVersion.V6_8);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV7() {
+    return getConfig(ElasticVersion.V7_3);
   }
 
   @Override
   public void configureIndex(Injector injector) throws Exception {
-    ElasticTestUtils.createAllIndexes(injector);
+    createAllIndexes(injector);
   }
 }
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 3f244a8..4384ab5 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
@@ -76,10 +76,7 @@
     userSshSession.assertFailure();
     String error = userSshSession.getError();
     assertThat(error).isNotNull();
-    assertError(
-        "One of the following capabilities is required to access this"
-            + " resource: [runGC, maintainServer]",
-        error);
+    assertError("maintain server not permitted", error);
   }
 
   @Test
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 591c6d6..50b2a78d 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
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.GitUtil.initSsh;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -25,6 +24,7 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gson.Gson;
@@ -288,24 +288,16 @@
   }
 
   @Test
-  public void queryWithNonVisibleCurrentPatchSet() throws Exception {
-    String changeId = createChange().getChangeId();
-    amendChangeAsDraft(changeId);
-    String query = "--current-patch-set --patch-sets " + changeId;
-    List<ChangeAttribute> changes = executeSuccessfulQuery(query);
-    assertThat(changes).hasSize(1);
-    assertThat(changes.get(0).patchSets).isNotNull();
-    assertThat(changes.get(0).patchSets).hasSize(2);
-    assertThat(changes.get(0).currentPatchSet).isNotNull();
-
-    SshSession userSession = new SshSession(server, user);
-    initSsh(user);
-    userSession.open();
-    changes = executeSuccessfulQuery(query, userSession);
-    assertThat(changes).hasSize(1);
-    assertThat(changes.get(0).patchSets).hasSize(1);
-    assertThat(changes.get(0).currentPatchSet).isNull();
-    userSession.close();
+  public void allChangeOptionsAreServedWithoutExceptions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // Merge the change so that the result has more data and potentially went through more
+    // computation while formatting the output, such as labels, reviewers etc.
+    merge(r);
+    for (ListChangesOption option : ListChangesOption.values()) {
+      assertThat(gApi.changes().query(r.getChangeId()).withOption(option).get())
+          .named("Option: " + option)
+          .hasSize(1);
+    }
   }
 
   private List<ChangeAttribute> executeSuccessfulQuery(String params, SshSession session)
diff --git a/gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt b/gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
new file mode 100644
index 0000000..4231f76
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
@@ -0,0 +1,1532 @@
+# Version 2017032102, Last Updated Wed Mar 22 07:07:01 2017 UTC
+# From http://data.iana.org/TLD/tlds-alpha-by-domain.txt
+AAA
+AARP
+ABARTH
+ABB
+ABBOTT
+ABBVIE
+ABC
+ABLE
+ABOGADO
+ABUDHABI
+AC
+ACADEMY
+ACCENTURE
+ACCOUNTANT
+ACCOUNTANTS
+ACO
+ACTIVE
+ACTOR
+AD
+ADAC
+ADS
+ADULT
+AE
+AEG
+AERO
+AETNA
+AF
+AFAMILYCOMPANY
+AFL
+#! AFRICA
+AG
+AGAKHAN
+AGENCY
+AI
+AIG
+AIGO
+AIRBUS
+AIRFORCE
+AIRTEL
+AKDN
+AL
+ALFAROMEO
+ALIBABA
+ALIPAY
+ALLFINANZ
+ALLSTATE
+ALLY
+ALSACE
+ALSTOM
+AM
+AMERICANEXPRESS
+AMERICANFAMILY
+AMEX
+AMFAM
+AMICA
+AMSTERDAM
+ANALYTICS
+ANDROID
+ANQUAN
+ANZ
+AO
+AOL
+APARTMENTS
+APP
+APPLE
+AQ
+AQUARELLE
+AR
+ARAMCO
+ARCHI
+ARMY
+ARPA
+ART
+ARTE
+AS
+ASDA
+ASIA
+ASSOCIATES
+AT
+ATHLETA
+ATTORNEY
+AU
+AUCTION
+AUDI
+AUDIBLE
+AUDIO
+AUSPOST
+AUTHOR
+AUTO
+AUTOS
+AVIANCA
+AW
+AWS
+AX
+AXA
+AZ
+AZURE
+BA
+BABY
+BAIDU
+BANAMEX
+BANANAREPUBLIC
+BAND
+BANK
+BAR
+BARCELONA
+BARCLAYCARD
+BARCLAYS
+BAREFOOT
+BARGAINS
+BASEBALL
+BASKETBALL
+BAUHAUS
+BAYERN
+BB
+BBC
+BBT
+BBVA
+BCG
+BCN
+BD
+BE
+BEATS
+BEAUTY
+BEER
+BENTLEY
+BERLIN
+BEST
+BESTBUY
+BET
+BF
+BG
+BH
+BHARTI
+BI
+BIBLE
+BID
+BIKE
+BING
+BINGO
+BIO
+BIZ
+BJ
+BLACK
+BLACKFRIDAY
+BLANCO
+BLOCKBUSTER
+BLOG
+BLOOMBERG
+BLUE
+BM
+BMS
+BMW
+BN
+BNL
+BNPPARIBAS
+BO
+BOATS
+BOEHRINGER
+BOFA
+BOM
+BOND
+BOO
+BOOK
+BOOKING
+BOOTS
+BOSCH
+BOSTIK
+BOSTON
+BOT
+BOUTIQUE
+BOX
+BR
+BRADESCO
+BRIDGESTONE
+BROADWAY
+BROKER
+BROTHER
+BRUSSELS
+BS
+BT
+BUDAPEST
+BUGATTI
+BUILD
+BUILDERS
+BUSINESS
+BUY
+BUZZ
+BV
+BW
+BY
+BZ
+BZH
+CA
+CAB
+CAFE
+CAL
+CALL
+CALVINKLEIN
+CAM
+CAMERA
+CAMP
+CANCERRESEARCH
+CANON
+CAPETOWN
+CAPITAL
+CAPITALONE
+CAR
+CARAVAN
+CARDS
+CARE
+CAREER
+CAREERS
+CARS
+CARTIER
+CASA
+CASE
+CASEIH
+CASH
+CASINO
+CAT
+CATERING
+CATHOLIC
+CBA
+CBN
+CBRE
+CBS
+CC
+CD
+CEB
+CENTER
+CEO
+CERN
+CF
+CFA
+CFD
+CG
+CH
+CHANEL
+CHANNEL
+CHASE
+CHAT
+CHEAP
+CHINTAI
+CHLOE
+CHRISTMAS
+CHROME
+CHRYSLER
+CHURCH
+CI
+CIPRIANI
+CIRCLE
+CISCO
+CITADEL
+CITI
+CITIC
+CITY
+CITYEATS
+CK
+CL
+CLAIMS
+CLEANING
+CLICK
+CLINIC
+CLINIQUE
+CLOTHING
+CLOUD
+CLUB
+CLUBMED
+CM
+CN
+CO
+COACH
+CODES
+COFFEE
+COLLEGE
+COLOGNE
+COM
+COMCAST
+COMMBANK
+COMMUNITY
+COMPANY
+COMPARE
+COMPUTER
+COMSEC
+CONDOS
+CONSTRUCTION
+CONSULTING
+CONTACT
+CONTRACTORS
+COOKING
+COOKINGCHANNEL
+COOL
+COOP
+CORSICA
+COUNTRY
+COUPON
+COUPONS
+COURSES
+CR
+CREDIT
+CREDITCARD
+CREDITUNION
+CRICKET
+CROWN
+CRS
+CRUISE
+CRUISES
+CSC
+CU
+CUISINELLA
+CV
+CW
+CX
+CY
+CYMRU
+CYOU
+CZ
+DABUR
+DAD
+DANCE
+DATA
+DATE
+DATING
+DATSUN
+DAY
+DCLK
+DDS
+DE
+DEAL
+DEALER
+DEALS
+DEGREE
+DELIVERY
+DELL
+DELOITTE
+DELTA
+DEMOCRAT
+DENTAL
+DENTIST
+DESI
+DESIGN
+DEV
+DHL
+DIAMONDS
+DIET
+DIGITAL
+DIRECT
+DIRECTORY
+DISCOUNT
+DISCOVER
+DISH
+DIY
+DJ
+DK
+DM
+DNP
+DO
+DOCS
+DOCTOR
+DODGE
+DOG
+DOHA
+DOMAINS
+DOT
+DOWNLOAD
+DRIVE
+DTV
+DUBAI
+DUCK
+DUNLOP
+DUNS
+DUPONT
+DURBAN
+DVAG
+DVR
+DZ
+EARTH
+EAT
+EC
+ECO
+EDEKA
+EDU
+EDUCATION
+EE
+EG
+EMAIL
+EMERCK
+ENERGY
+ENGINEER
+ENGINEERING
+ENTERPRISES
+EPOST
+EPSON
+EQUIPMENT
+ER
+ERICSSON
+ERNI
+ES
+ESQ
+ESTATE
+ESURANCE
+ET
+EU
+EUROVISION
+EUS
+EVENTS
+EVERBANK
+EXCHANGE
+EXPERT
+EXPOSED
+EXPRESS
+EXTRASPACE
+FAGE
+FAIL
+FAIRWINDS
+FAITH
+FAMILY
+FAN
+FANS
+FARM
+FARMERS
+FASHION
+FAST
+FEDEX
+FEEDBACK
+FERRARI
+FERRERO
+FI
+FIAT
+FIDELITY
+FIDO
+FILM
+FINAL
+FINANCE
+FINANCIAL
+FIRE
+FIRESTONE
+FIRMDALE
+FISH
+FISHING
+FIT
+FITNESS
+FJ
+FK
+FLICKR
+FLIGHTS
+FLIR
+FLORIST
+FLOWERS
+FLY
+FM
+FO
+FOO
+FOOD
+FOODNETWORK
+FOOTBALL
+FORD
+FOREX
+FORSALE
+FORUM
+FOUNDATION
+FOX
+FR
+FREE
+FRESENIUS
+FRL
+FROGANS
+FRONTDOOR
+FRONTIER
+FTR
+FUJITSU
+FUJIXEROX
+FUN
+FUND
+FURNITURE
+FUTBOL
+FYI
+GA
+GAL
+GALLERY
+GALLO
+GALLUP
+GAME
+GAMES
+GAP
+GARDEN
+GB
+GBIZ
+GD
+GDN
+GE
+GEA
+GENT
+GENTING
+GEORGE
+GF
+GG
+GGEE
+GH
+GI
+GIFT
+GIFTS
+GIVES
+GIVING
+GL
+GLADE
+GLASS
+GLE
+GLOBAL
+GLOBO
+GM
+GMAIL
+GMBH
+GMO
+GMX
+GN
+GODADDY
+GOLD
+GOLDPOINT
+GOLF
+GOO
+GOODHANDS
+GOODYEAR
+GOOG
+GOOGLE
+GOP
+GOT
+GOV
+GP
+GQ
+GR
+GRAINGER
+GRAPHICS
+GRATIS
+GREEN
+GRIPE
+GROUP
+GS
+GT
+GU
+GUARDIAN
+GUCCI
+GUGE
+GUIDE
+GUITARS
+GURU
+GW
+GY
+HAIR
+HAMBURG
+HANGOUT
+HAUS
+HBO
+HDFC
+HDFCBANK
+HEALTH
+HEALTHCARE
+HELP
+HELSINKI
+HERE
+HERMES
+HGTV
+HIPHOP
+HISAMITSU
+HITACHI
+HIV
+HK
+HKT
+HM
+HN
+HOCKEY
+HOLDINGS
+HOLIDAY
+HOMEDEPOT
+HOMEGOODS
+HOMES
+HOMESENSE
+HONDA
+HONEYWELL
+HORSE
+HOSPITAL
+HOST
+HOSTING
+HOT
+HOTELES
+HOTMAIL
+HOUSE
+HOW
+HR
+HSBC
+HT
+HTC
+HU
+HUGHES
+HYATT
+HYUNDAI
+IBM
+ICBC
+ICE
+ICU
+ID
+IE
+IEEE
+IFM
+IKANO
+IL
+IM
+IMAMAT
+IMDB
+IMMO
+IMMOBILIEN
+IN
+INDUSTRIES
+INFINITI
+INFO
+ING
+INK
+INSTITUTE
+INSURANCE
+INSURE
+INT
+INTEL
+INTERNATIONAL
+INTUIT
+INVESTMENTS
+IO
+IPIRANGA
+IQ
+IR
+IRISH
+IS
+ISELECT
+ISMAILI
+IST
+ISTANBUL
+IT
+ITAU
+ITV
+IVECO
+IWC
+JAGUAR
+JAVA
+JCB
+JCP
+JE
+JEEP
+JETZT
+JEWELRY
+JIO
+JLC
+JLL
+JM
+JMP
+JNJ
+JO
+JOBS
+JOBURG
+JOT
+JOY
+JP
+JPMORGAN
+JPRS
+JUEGOS
+JUNIPER
+KAUFEN
+KDDI
+KE
+KERRYHOTELS
+KERRYLOGISTICS
+KERRYPROPERTIES
+KFH
+KG
+KH
+KI
+KIA
+KIM
+KINDER
+KINDLE
+KITCHEN
+KIWI
+KM
+KN
+KOELN
+KOMATSU
+KOSHER
+KP
+KPMG
+KPN
+KR
+KRD
+KRED
+KUOKGROUP
+KW
+KY
+KYOTO
+KZ
+LA
+LACAIXA
+LADBROKES
+LAMBORGHINI
+LAMER
+LANCASTER
+LANCIA
+LANCOME
+LAND
+LANDROVER
+LANXESS
+LASALLE
+LAT
+LATINO
+LATROBE
+LAW
+LAWYER
+LB
+LC
+LDS
+LEASE
+LECLERC
+LEFRAK
+LEGAL
+LEGO
+LEXUS
+LGBT
+LI
+LIAISON
+LIDL
+LIFE
+LIFEINSURANCE
+LIFESTYLE
+LIGHTING
+LIKE
+LILLY
+LIMITED
+LIMO
+LINCOLN
+LINDE
+LINK
+LIPSY
+LIVE
+LIVING
+LIXIL
+LK
+LOAN
+LOANS
+LOCKER
+LOCUS
+LOFT
+LOL
+LONDON
+LOTTE
+LOTTO
+LOVE
+LPL
+LPLFINANCIAL
+LR
+LS
+LT
+LTD
+LTDA
+LU
+LUNDBECK
+LUPIN
+LUXE
+LUXURY
+LV
+LY
+MA
+MACYS
+MADRID
+MAIF
+MAISON
+MAKEUP
+MAN
+MANAGEMENT
+MANGO
+MARKET
+MARKETING
+MARKETS
+MARRIOTT
+MARSHALLS
+MASERATI
+MATTEL
+MBA
+MC
+MCD
+MCDONALDS
+MCKINSEY
+MD
+ME
+MED
+MEDIA
+MEET
+MELBOURNE
+MEME
+MEMORIAL
+MEN
+MENU
+MEO
+METLIFE
+MG
+MH
+MIAMI
+MICROSOFT
+MIL
+MINI
+MINT
+MIT
+MITSUBISHI
+MK
+ML
+MLB
+MLS
+MM
+MMA
+MN
+MO
+MOBI
+MOBILE
+MOBILY
+MODA
+MOE
+MOI
+MOM
+MONASH
+MONEY
+MONSTER
+MONTBLANC
+MOPAR
+MORMON
+MORTGAGE
+MOSCOW
+MOTO
+MOTORCYCLES
+MOV
+MOVIE
+MOVISTAR
+MP
+MQ
+MR
+MS
+MSD
+MT
+MTN
+MTPC
+MTR
+MU
+MUSEUM
+MUTUAL
+MV
+MW
+MX
+MY
+MZ
+NA
+NAB
+NADEX
+NAGOYA
+NAME
+NATIONWIDE
+NATURA
+NAVY
+NBA
+NC
+NE
+NEC
+NET
+NETBANK
+NETFLIX
+NETWORK
+NEUSTAR
+NEW
+NEWHOLLAND
+NEWS
+NEXT
+NEXTDIRECT
+NEXUS
+NF
+NFL
+NG
+NGO
+NHK
+NI
+NICO
+NIKE
+NIKON
+NINJA
+NISSAN
+NISSAY
+NL
+NO
+NOKIA
+NORTHWESTERNMUTUAL
+NORTON
+NOW
+NOWRUZ
+NOWTV
+NP
+NR
+NRA
+NRW
+NTT
+NU
+NYC
+NZ
+OBI
+OBSERVER
+OFF
+OFFICE
+OKINAWA
+OLAYAN
+OLAYANGROUP
+OLDNAVY
+OLLO
+OM
+OMEGA
+ONE
+ONG
+ONL
+ONLINE
+ONYOURSIDE
+OOO
+OPEN
+ORACLE
+ORANGE
+ORG
+ORGANIC
+ORIENTEXPRESS
+ORIGINS
+OSAKA
+OTSUKA
+OTT
+OVH
+PA
+PAGE
+PAMPEREDCHEF
+PANASONIC
+PANERAI
+PARIS
+PARS
+PARTNERS
+PARTS
+PARTY
+PASSAGENS
+PAY
+PCCW
+PE
+PET
+PF
+PFIZER
+PG
+PH
+PHARMACY
+PHILIPS
+PHONE
+PHOTO
+PHOTOGRAPHY
+PHOTOS
+PHYSIO
+PIAGET
+PICS
+PICTET
+PICTURES
+PID
+PIN
+PING
+PINK
+PIONEER
+PIZZA
+PK
+PL
+PLACE
+PLAY
+PLAYSTATION
+PLUMBING
+PLUS
+PM
+PN
+PNC
+POHL
+POKER
+POLITIE
+PORN
+POST
+PR
+PRAMERICA
+PRAXI
+PRESS
+PRIME
+PRO
+PROD
+PRODUCTIONS
+PROF
+PROGRESSIVE
+PROMO
+PROPERTIES
+PROPERTY
+PROTECTION
+PRU
+PRUDENTIAL
+PS
+PT
+PUB
+PW
+PWC
+PY
+QA
+QPON
+QUEBEC
+QUEST
+QVC
+RACING
+RADIO
+RAID
+RE
+READ
+REALESTATE
+REALTOR
+REALTY
+RECIPES
+RED
+REDSTONE
+REDUMBRELLA
+REHAB
+REISE
+REISEN
+REIT
+RELIANCE
+REN
+RENT
+RENTALS
+REPAIR
+REPORT
+REPUBLICAN
+REST
+RESTAURANT
+REVIEW
+REVIEWS
+REXROTH
+RICH
+RICHARDLI
+RICOH
+RIGHTATHOME
+RIL
+RIO
+RIP
+RMIT
+RO
+ROCHER
+ROCKS
+RODEO
+ROGERS
+ROOM
+RS
+RSVP
+RU
+RUHR
+RUN
+RW
+RWE
+RYUKYU
+SA
+SAARLAND
+SAFE
+SAFETY
+SAKURA
+SALE
+SALON
+SAMSCLUB
+SAMSUNG
+SANDVIK
+SANDVIKCOROMANT
+SANOFI
+SAP
+SAPO
+SARL
+SAS
+SAVE
+SAXO
+SB
+SBI
+SBS
+SC
+SCA
+SCB
+SCHAEFFLER
+SCHMIDT
+SCHOLARSHIPS
+SCHOOL
+SCHULE
+SCHWARZ
+SCIENCE
+SCJOHNSON
+SCOR
+SCOT
+SD
+SE
+SEAT
+SECURE
+SECURITY
+SEEK
+SELECT
+SENER
+SERVICES
+SES
+SEVEN
+SEW
+SEX
+SEXY
+SFR
+SG
+SH
+SHANGRILA
+SHARP
+SHAW
+SHELL
+SHIA
+SHIKSHA
+SHOES
+SHOP
+SHOPPING
+SHOUJI
+SHOW
+SHOWTIME
+SHRIRAM
+SI
+SILK
+SINA
+SINGLES
+SITE
+SJ
+SK
+SKI
+SKIN
+SKY
+SKYPE
+SL
+SLING
+SM
+SMART
+SMILE
+SN
+SNCF
+SO
+SOCCER
+SOCIAL
+SOFTBANK
+SOFTWARE
+SOHU
+SOLAR
+SOLUTIONS
+SONG
+SONY
+SOY
+SPACE
+SPIEGEL
+SPOT
+SPREADBETTING
+SR
+SRL
+SRT
+ST
+STADA
+STAPLES
+STAR
+STARHUB
+STATEBANK
+STATEFARM
+STATOIL
+STC
+STCGROUP
+STOCKHOLM
+STORAGE
+STORE
+STREAM
+STUDIO
+STUDY
+STYLE
+SU
+SUCKS
+SUPPLIES
+SUPPLY
+SUPPORT
+SURF
+SURGERY
+SUZUKI
+SV
+SWATCH
+SWIFTCOVER
+SWISS
+SX
+SY
+SYDNEY
+SYMANTEC
+SYSTEMS
+SZ
+TAB
+TAIPEI
+TALK
+TAOBAO
+TARGET
+TATAMOTORS
+TATAR
+TATTOO
+TAX
+TAXI
+TC
+TCI
+TD
+TDK
+TEAM
+TECH
+TECHNOLOGY
+TEL
+TELECITY
+TELEFONICA
+TEMASEK
+TENNIS
+TEVA
+TF
+TG
+TH
+THD
+THEATER
+THEATRE
+TIAA
+TICKETS
+TIENDA
+TIFFANY
+TIPS
+TIRES
+TIROL
+TJ
+TJMAXX
+TJX
+TK
+TKMAXX
+TL
+TM
+TMALL
+TN
+TO
+TODAY
+TOKYO
+TOOLS
+TOP
+TORAY
+TOSHIBA
+TOTAL
+TOURS
+TOWN
+TOYOTA
+TOYS
+TR
+TRADE
+TRADING
+TRAINING
+TRAVEL
+TRAVELCHANNEL
+TRAVELERS
+TRAVELERSINSURANCE
+TRUST
+TRV
+TT
+TUBE
+TUI
+TUNES
+TUSHU
+TV
+TVS
+TW
+TZ
+UA
+UBANK
+UBS
+UCONNECT
+UG
+UK
+UNICOM
+UNIVERSITY
+UNO
+UOL
+UPS
+US
+UY
+UZ
+VA
+VACATIONS
+VANA
+VANGUARD
+VC
+VE
+VEGAS
+VENTURES
+VERISIGN
+VERSICHERUNG
+VET
+VG
+VI
+VIAJES
+VIDEO
+VIG
+VIKING
+VILLAS
+VIN
+VIP
+VIRGIN
+VISA
+VISION
+VISTA
+VISTAPRINT
+VIVA
+VIVO
+VLAANDEREN
+VN
+VODKA
+VOLKSWAGEN
+VOLVO
+VOTE
+VOTING
+VOTO
+VOYAGE
+VU
+VUELOS
+WALES
+WALMART
+WALTER
+WANG
+WANGGOU
+WARMAN
+WATCH
+WATCHES
+WEATHER
+WEATHERCHANNEL
+WEBCAM
+WEBER
+WEBSITE
+WED
+WEDDING
+WEIBO
+WEIR
+WF
+WHOSWHO
+WIEN
+WIKI
+WILLIAMHILL
+WIN
+WINDOWS
+WINE
+WINNERS
+WME
+WOLTERSKLUWER
+WOODSIDE
+WORK
+WORKS
+WORLD
+WOW
+WS
+WTC
+WTF
+XBOX
+XEROX
+XFINITY
+XIHUAN
+XIN
+XN--11B4C3D
+XN--1CK2E1B
+XN--1QQW23A
+XN--30RR7Y
+XN--3BST00M
+XN--3DS443G
+XN--3E0B707E
+XN--3OQ18VL8PN36A
+XN--3PXU8K
+XN--42C2D9A
+XN--45BRJ9C
+XN--45Q11C
+XN--4GBRIM
+XN--54B7FTA0CC
+XN--55QW42G
+XN--55QX5D
+XN--5SU34J936BGSG
+XN--5TZM5G
+XN--6FRZ82G
+XN--6QQ986B3XL
+XN--80ADXHKS
+XN--80AO21A
+XN--80AQECDR1A
+XN--80ASEHDB
+XN--80ASWG
+XN--8Y0A063A
+XN--90A3AC
+XN--90AE
+XN--90AIS
+XN--9DBQ2A
+XN--9ET52U
+XN--9KRT00A
+XN--B4W605FERD
+XN--BCK1B9A5DRE4C
+XN--C1AVG
+XN--C2BR7G
+XN--CCK2B3B
+XN--CG4BKI
+XN--CLCHC0EA0B2G2A9GCD
+XN--CZR694B
+XN--CZRS0T
+XN--CZRU2D
+XN--D1ACJ3B
+XN--D1ALF
+XN--E1A4C
+XN--ECKVDTC9D
+XN--EFVY88H
+XN--ESTV75G
+XN--FCT429K
+XN--FHBEI
+XN--FIQ228C5HS
+XN--FIQ64B
+XN--FIQS8S
+XN--FIQZ9S
+XN--FJQ720A
+XN--FLW351E
+XN--FPCRJ9C3D
+XN--FZC2C9E2C
+XN--FZYS8D69UVGM
+XN--G2XX48C
+XN--GCKR3F0F
+XN--GECRJ9C
+XN--GK3AT1E
+XN--H2BRJ9C
+XN--HXT814E
+XN--I1B6B1A6A2E
+XN--IMR513N
+XN--IO0A7I
+XN--J1AEF
+XN--J1AMH
+XN--J6W193G
+XN--JLQ61U9W7B
+XN--JVR189M
+XN--KCRX77D1X4A
+XN--KPRW13D
+XN--KPRY57D
+XN--KPU716F
+XN--KPUT3I
+XN--L1ACC
+XN--LGBBAT1AD8J
+XN--MGB9AWBF
+XN--MGBA3A3EJT
+XN--MGBA3A4F16A
+XN--MGBA7C0BBN0A
+XN--MGBAAM7A8H
+XN--MGBAB2BD
+XN--MGBAI9AZGQP6J
+XN--MGBAYH7GPA
+XN--MGBB9FBPOB
+XN--MGBBH1A71E
+XN--MGBC0A9AZCG
+XN--MGBCA7DZDO
+XN--MGBERP4A5D4AR
+XN--MGBI4ECEXP
+XN--MGBPL2FH
+XN--MGBT3DHD
+XN--MGBTX2B
+XN--MGBX4CD0AB
+XN--MIX891F
+XN--MK1BU44C
+XN--MXTQ1M
+XN--NGBC5AZD
+XN--NGBE9E0A
+XN--NODE
+XN--NQV7F
+XN--NQV7FS00EMA
+XN--NYQY26A
+XN--O3CW4H
+XN--OGBPF8FL
+XN--P1ACF
+XN--P1AI
+XN--PBT977C
+XN--PGBS0DH
+XN--PSSY2U
+XN--Q9JYB4C
+XN--QCKA1PMC
+XN--QXAM
+XN--RHQV96G
+XN--ROVU88B
+XN--S9BRJ9C
+XN--SES554G
+XN--T60B56A
+XN--TCKWE
+XN--TIQ49XQYJ
+XN--UNUP4Y
+XN--VERMGENSBERATER-CTB
+XN--VERMGENSBERATUNG-PWB
+XN--VHQUV
+XN--VUQ861B
+XN--W4R85EL8FHU5DNRA
+XN--W4RS40L
+XN--WGBH1C
+XN--WGBL6A
+XN--XHQ521B
+XN--XKC2AL3HYE2A
+XN--XKC2DL3A5EE0H
+XN--Y9A3AQ
+XN--YFRO4I67O
+XN--YGBI2AMMX
+XN--ZFR164B
+XPERIA
+XXX
+XYZ
+YACHTS
+YAHOO
+YAMAXUN
+YANDEX
+YE
+YODOBASHI
+YOGA
+YOKOHAMA
+YOU
+YOUTUBE
+YT
+YUN
+ZA
+ZAPPOS
+ZARA
+ZERO
+ZIP
+ZIPPO
+ZM
+ZONE
+ZUERICH
+ZW
diff --git a/gerrit-antlr/BUILD b/gerrit-antlr/BUILD
deleted file mode 100644
index 19bcaf6..0000000
--- a/gerrit-antlr/BUILD
+++ /dev/null
@@ -1,36 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-load("//tools/bzl:genrule2.bzl", "genrule2")
-
-java_library(
-    name = "query_exception",
-    srcs = [
-        "src/main/java/com/google/gerrit/server/query/QueryParseException.java",
-        "src/main/java/com/google/gerrit/server/query/QueryRequiresAuthException.java",
-    ],
-    visibility = ["//visibility:public"],
-)
-
-genrule2(
-    name = "query_antlr",
-    srcs = ["src/main/antlr3/com/google/gerrit/server/query/Query.g"],
-    outs = ["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-antlr/src/main/antlr3/com/google/gerrit/server/query/Query.g b/gerrit-antlr/src/main/antlr3/com/google/gerrit/server/query/Query.g
deleted file mode 100644
index d0b5875..0000000
--- a/gerrit-antlr/src/main/antlr3/com/google/gerrit/server/query/Query.g
+++ /dev/null
@@ -1,185 +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.
-
-grammar Query;
-options {
-  language = Java;
-  output = AST;
-}
-
-tokens {
-  AND;
-  OR;
-  NOT;
-  DEFAULT_FIELD;
-}
-
-@header {
-package com.google.gerrit.server.query;
-}
-@members {
-  static class QueryParseInternalException extends RuntimeException {
-    private static final long serialVersionUID = 1L;
-
-    QueryParseInternalException(final String msg) {
-      super(msg);
-    }
-  }
-
-  public static Tree parse(final String str)
-    throws QueryParseException {
-    try {
-      final QueryParser p = new QueryParser(
-        new TokenRewriteStream(
-          new QueryLexer(
-            new ANTLRStringStream(str)
-          )
-        )
-      );
-      return (Tree)p.query().getTree();
-    } catch (QueryParseInternalException e) {
-      throw new QueryParseException(e.getMessage());
-    } catch (RecognitionException e) {
-      throw new QueryParseException(e.getMessage());
-    }
-  }
-
-  static boolean isSingleWord(final String value) {
-    try {
-      final QueryLexer lexer = new QueryLexer(new ANTLRStringStream(value));
-      lexer.mSINGLE_WORD();
-      return lexer.nextToken().getType() == QueryParser.EOF;
-    } catch (QueryParseInternalException e) {
-      return false;
-    } catch (RecognitionException e) {
-      return false;
-    }
-  }
-
-  @Override
-  public void displayRecognitionError(String[] tokenNames,
-                                      RecognitionException e) {
-      String hdr = getErrorHeader(e);
-      String msg = getErrorMessage(e, tokenNames);
-      throw new QueryParseInternalException(hdr + " " + msg);
-  }
-}
-
-@lexer::header {
-package com.google.gerrit.server.query;
-}
-@lexer::members {
-  @Override
-  public void displayRecognitionError(String[] tokenNames,
-                                      RecognitionException e) {
-      String hdr = getErrorHeader(e);
-      String msg = getErrorMessage(e, tokenNames);
-      throw new QueryParser.QueryParseInternalException(hdr + " " + msg);
-  }
-}
-
-query
-  : conditionOr
-  ;
-
-conditionOr
-  : (conditionAnd OR)
-    => conditionAnd OR^ conditionAnd (OR! conditionAnd)*
-  | conditionAnd
-  ;
-
-conditionAnd
-  : (conditionNot AND)
-    => i+=conditionNot (i+=conditionAnd2)*
-    -> ^(AND $i+)
-  | (conditionNot conditionNot)
-    => i+=conditionNot (i+=conditionAnd2)*
-    -> ^(AND $i+)
-  | conditionNot
-  ;
-conditionAnd2
-  : AND! conditionNot
-  | conditionNot
-  ;
-
-conditionNot
-  : '-' conditionBase -> ^(NOT conditionBase)
-  | NOT^ conditionBase
-  | conditionBase
-  ;
-conditionBase
-  : '('! conditionOr ')'!
-  | (FIELD_NAME ':') => FIELD_NAME^ ':'! fieldValue
-  | fieldValue -> ^(DEFAULT_FIELD fieldValue)
-  ;
-
-fieldValue
-  : n=FIELD_NAME   -> SINGLE_WORD[n]
-  | SINGLE_WORD
-  | EXACT_PHRASE
-  ;
-
-AND: 'AND' ;
-OR:  'OR'  ;
-NOT: 'NOT' ;
-
-WS
-  :  ( ' ' | '\r' | '\t' | '\n' ) { $channel=HIDDEN; }
-  ;
-
-FIELD_NAME
-  : ('a'..'z' | '_')+
-  ;
-
-EXACT_PHRASE
-  : '"' ( ~('"') )* '"' {
-      String s = $text;
-      setText(s.substring(1, s.length() - 1));
-    }
-  | '{' ( ~('{'|'}') )* '}' {
-      String s = $text;
-      setText(s.substring(1, s.length() - 1));
-    }
-  ;
-
-SINGLE_WORD
-  : ~( '-' | NON_WORD ) ( ~( NON_WORD ) )*
-  ;
-fragment NON_WORD
-  :  ( '\u0000'..' '
-     | '!'
-     | '"'
-     // '#' permit
-     | '$'
-     | '%'
-     | '&'
-     | '\''
-     | '(' | ')'
-     // '*'  permit
-     // '+'  permit
-     // ','  permit
-     // '-'  permit
-     // '.'  permit
-     // '/'  permit
-     | ':'
-     | ';'
-     // '<' permit
-     // '=' permit
-     // '>' permit
-     | '?'
-     | '[' | ']'
-     | '{' | '}'
-     // | '~' permit
-     )
-  ;
diff --git a/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryParseException.java b/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryParseException.java
deleted file mode 100644
index 80cffbb..0000000
--- a/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryParseException.java
+++ /dev/null
@@ -1,32 +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.query;
-
-/**
- * Exception thrown when a search query is invalid.
- *
- * <p><b>NOTE:</b> the message is visible to end users.
- */
-public class QueryParseException extends Exception {
-  private static final long serialVersionUID = 1L;
-
-  public QueryParseException(final String message) {
-    super(message);
-  }
-
-  public QueryParseException(final String msg, final Throwable why) {
-    super(msg, why);
-  }
-}
diff --git a/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryRequiresAuthException.java b/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryRequiresAuthException.java
deleted file mode 100644
index a41e54f..0000000
--- a/gerrit-antlr/src/main/java/com/google/gerrit/server/query/QueryRequiresAuthException.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// 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.query;
-
-/**
- * Exception thrown when a search query is invalid.
- *
- * <p><b>NOTE:</b> the message is visible to end users.
- */
-public class QueryRequiresAuthException extends QueryParseException {
-  private static final long serialVersionUID = 1L;
-
-  public QueryRequiresAuthException(String message) {
-    super(message);
-  }
-
-  public QueryRequiresAuthException(String msg, Throwable why) {
-    super(msg, why);
-  }
-}
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 78a32bd..a2c0d15 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
@@ -116,7 +116,7 @@
   @Override
   public void start() {
     if (executor != null) {
-      for (final H2CacheImpl<?, ?> cache : caches) {
+      for (H2CacheImpl<?, ?> cache : caches) {
         executor.execute(cache::start);
         @SuppressWarnings("unused")
         Future<?> possiblyIgnoredError =
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 3b86c95..eaa9af9 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
@@ -131,11 +131,26 @@
 
   @Override
   public V get(K key, Callable<? extends V> valueLoader) throws ExecutionException {
-    return mem.get(key, new LoadingCallable(key, valueLoader)).value;
+    return mem.get(
+            key,
+            () -> {
+              if (store.mightContain(key)) {
+                ValueHolder<V> h = store.getIfPresent(key);
+                if (h != null) {
+                  return h;
+                }
+              }
+
+              ValueHolder<V> h = new ValueHolder<>(valueLoader.call());
+              h.created = TimeUtil.nowMs();
+              executor.execute(() -> store.put(key, h));
+              return h;
+            })
+        .value;
   }
 
   @Override
-  public void put(final K key, V val) {
+  public void put(K key, V val) {
     final ValueHolder<V> h = new ValueHolder<>(val);
     h.created = TimeUtil.nowMs();
     mem.put(key, h);
@@ -144,7 +159,7 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public void invalidate(final Object key) {
+  public void invalidate(Object key) {
     if (keyType.getRawType().isInstance(key) && store.mightContain((K) key)) {
       executor.execute(() -> store.invalidate((K) key));
     }
@@ -186,7 +201,7 @@
     store.close();
   }
 
-  void prune(final ScheduledExecutorService service) {
+  void prune(ScheduledExecutorService service) {
     store.prune(mem);
 
     Calendar cal = Calendar.getInstance();
@@ -224,7 +239,7 @@
     }
 
     @Override
-    public ValueHolder<V> load(final K key) throws Exception {
+    public ValueHolder<V> load(K key) throws Exception {
       if (store.mightContain(key)) {
         ValueHolder<V> h = store.getIfPresent(key);
         if (h != null) {
@@ -239,31 +254,6 @@
     }
   }
 
-  private class LoadingCallable implements Callable<ValueHolder<V>> {
-    private final K key;
-    private final Callable<? extends V> loader;
-
-    LoadingCallable(K key, Callable<? extends V> loader) {
-      this.key = key;
-      this.loader = loader;
-    }
-
-    @Override
-    public ValueHolder<V> call() throws Exception {
-      if (store.mightContain(key)) {
-        ValueHolder<V> h = store.getIfPresent(key);
-        if (h != null) {
-          return h;
-        }
-      }
-
-      final ValueHolder<V> h = new ValueHolder<>(loader.call());
-      h.created = TimeUtil.nowMs();
-      executor.execute(() -> store.put(key, h));
-      return h;
-    }
-  }
-
   private static class KeyType<K> {
     String columnType() {
       return "OTHER";
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 15e0de0..80bca6d 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
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.inject.TypeLiteral;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.junit.Before;
@@ -54,12 +53,9 @@
     assertTrue(
         impl.get(
             "foo",
-            new Callable<Boolean>() {
-              @Override
-              public Boolean call() throws Exception {
-                called.set(true);
-                return true;
-              }
+            () -> {
+              called.set(true);
+              return true;
             }));
     assertTrue("used Callable", called.get());
     assertTrue("exists in cache", impl.getIfPresent("foo"));
@@ -70,12 +66,9 @@
     assertTrue(
         impl.get(
             "foo",
-            new Callable<Boolean>() {
-              @Override
-              public Boolean call() throws Exception {
-                called.set(true);
-                return true;
-              }
+            () -> {
+              called.set(true);
+              return true;
             }));
     assertFalse("did not invoke Callable", called.get());
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/Die.java b/gerrit-common/src/main/java/com/google/gerrit/common/Die.java
index 6a1f304..5ad5ae8 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/Die.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/Die.java
@@ -17,11 +17,11 @@
 public class Die extends RuntimeException {
   private static final long serialVersionUID = 1L;
 
-  public Die(final String why) {
+  public Die(String why) {
     super(why);
   }
 
-  public Die(final String why, final Throwable cause) {
+  public Die(String why, Throwable cause) {
     super(why, cause);
   }
 }
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 4c5583f..24e3808 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
@@ -39,18 +39,18 @@
     return !Arrays.equals(curVers, newVers);
   }
 
-  public static void mkdir(final File path) {
+  public static void mkdir(File path) {
     if (!path.isDirectory() && !path.mkdir()) {
       throw new Die("Cannot make directory " + path);
     }
   }
 
-  public static void chmod(final int mode, final Path path) {
+  public static void chmod(int mode, Path path) {
     // TODO(dborowitz): Is there a portable way to do this with NIO?
     chmod(mode, path.toFile());
   }
 
-  public static void chmod(final int mode, final File path) {
+  public static void chmod(int mode, File path) {
     path.setReadable(false, false /* all */);
     path.setWritable(false, false /* all */);
     path.setExecutable(false, false /* all */);
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 624bcea..526e88b 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
@@ -32,7 +32,7 @@
 
 @GwtIncompatible("Unemulated methods in Class and OutputStream")
 public final class IoUtil {
-  public static void copyWithThread(final InputStream src, final OutputStream dst) {
+  public static void copyWithThread(InputStream src, OutputStream dst) {
     new Thread("IoUtil-Copy") {
       @Override
       public void run() {
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 692285f..97e7ff3 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
@@ -22,6 +22,8 @@
 import com.google.gwtorm.client.KeyUtil;
 
 public class PageLinks {
+  public static final String PROJECT_CHANGE_DELIMITER = "/+/";
+
   public static final String SETTINGS = "/settings/";
   public static final String SETTINGS_PREFERENCES = "/settings/preferences";
   public static final String SETTINGS_DIFF_PREFERENCES = "/settings/diff-preferences";
@@ -51,20 +53,21 @@
   public static final String MY_GROUPS = "/groups/self";
   public static final String DOCUMENTATION = "/Documentation/";
 
-  public static String toChangeInEditMode(Change.Id c) {
-    return "/c/" + c + ",edit/";
+  public static String toChangeInEditMode(@Nullable Project.NameKey project, Change.Id c) {
+    return toChangeNoSlash(project, c) + ",edit/";
   }
 
-  public static String toChange(final Change.Id c) {
-    return "/c/" + c + "/";
+  public static String toChange(@Nullable Project.NameKey project, Change.Id c) {
+    return toChangeNoSlash(project, c) + "/";
   }
 
-  public static String toChange(Change.Id c, String p) {
-    return "/c/" + c + "/" + p;
+  public static String toChange(@Nullable Project.NameKey project, Change.Id c, String p) {
+    return toChange(project, c) + p;
   }
 
-  public static String toChange(Change.Id c, String b, String p) {
-    String u = "/c/" + c + "/";
+  public static String toChange(
+      @Nullable Project.NameKey project, Change.Id c, String b, String p) {
+    String u = toChange(project, c);
     if (b != null) {
       u += b + "..";
     }
@@ -72,15 +75,22 @@
     return u;
   }
 
-  public static String toChange(final PatchSet.Id ps) {
-    return "/c/" + ps.getParentKey() + "/" + ps.getId();
+  public static String toChangeId(@Nullable Project.NameKey project, Change.Id c) {
+    if (project == null) {
+      return String.valueOf(c.get());
+    }
+    return project.get() + PROJECT_CHANGE_DELIMITER + c.get();
   }
 
-  public static String toProject(final Project.NameKey p) {
+  public static String toChange(@Nullable Project.NameKey project, PatchSet.Id ps) {
+    return toChange(project, ps.getParentKey()) + ps.getId();
+  }
+
+  public static String toProject(Project.NameKey p) {
     return ADMIN_PROJECTS + p.get();
   }
 
-  public static String toProjectAcceess(final Project.NameKey p) {
+  public static String toProjectAcceess(Project.NameKey p) {
     return "/admin/projects/" + p.get() + ",access";
   }
 
@@ -100,7 +110,7 @@
     return toChangeQuery(op("assignee", fullname));
   }
 
-  public static String toCustomDashboard(final String params) {
+  public static String toCustomDashboard(String params) {
     return "/dashboard/?" + params;
   }
 
@@ -132,7 +142,6 @@
     switch (status) {
       case ABANDONED:
         return toChangeQuery(status(status) + " " + op("topic", topic));
-      case DRAFT:
       case MERGED:
       case NEW:
         return toChangeQuery(
@@ -159,13 +168,19 @@
         return "status:abandoned";
       case MERGED:
         return "status:merged";
-      case DRAFT:
       case NEW:
       default:
         return "status:open";
     }
   }
 
+  private static String toChangeNoSlash(@Nullable Project.NameKey project, Change.Id c) {
+    if (project != null) {
+      return "/c/" + project.get() + PROJECT_CHANGE_DELIMITER + c;
+    }
+    return "/c/" + c;
+  }
+
   public static String op(String op, int value) {
     return op + ":" + value;
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java
index 5be0878..0369bfe 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/ProjectAccessUtil.java
@@ -26,7 +26,7 @@
 public class ProjectAccessUtil {
   public static List<AccessSection> mergeSections(List<AccessSection> src) {
     Map<String, AccessSection> map = new LinkedHashMap<>();
-    for (final AccessSection section : src) {
+    for (AccessSection section : src) {
       if (section.getPermissions().isEmpty()) {
         continue;
       }
@@ -44,21 +44,21 @@
   public static List<AccessSection> removeEmptyPermissionsAndSections(
       final List<AccessSection> src) {
     final Set<AccessSection> sectionsToRemove = new HashSet<>();
-    for (final AccessSection section : src) {
+    for (AccessSection section : src) {
       final Set<Permission> permissionsToRemove = new HashSet<>();
-      for (final Permission permission : section.getPermissions()) {
+      for (Permission permission : section.getPermissions()) {
         if (permission.getRules().isEmpty()) {
           permissionsToRemove.add(permission);
         }
       }
-      for (final Permission permissionToRemove : permissionsToRemove) {
+      for (Permission permissionToRemove : permissionsToRemove) {
         section.remove(permissionToRemove);
       }
       if (section.getPermissions().isEmpty()) {
         sectionsToRemove.add(section);
       }
     }
-    for (final AccessSection sectionToRemove : sectionsToRemove) {
+    for (AccessSection sectionToRemove : sectionsToRemove) {
       src.remove(sectionToRemove);
     }
     return src;
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
index 961f43a..f59d4a9 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/RawInputUtil.java
@@ -30,7 +30,7 @@
     return create(content.getBytes(UTF_8));
   }
 
-  public static RawInput create(final byte[] bytes, final String contentType) {
+  public static RawInput create(byte[] bytes, String contentType) {
     Preconditions.checkNotNull(bytes);
     Preconditions.checkArgument(bytes.length > 0);
     return new RawInput() {
@@ -51,11 +51,11 @@
     };
   }
 
-  public static RawInput create(final byte[] bytes) {
+  public static RawInput create(byte[] bytes) {
     return create(bytes, "application/octet-stream");
   }
 
-  public static RawInput create(final HttpServletRequest req) {
+  public static RawInput create(HttpServletRequest req) {
     return new RawInput() {
       @Override
       public String getContentType() {
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 4e14514..cfecd78 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
@@ -121,7 +121,7 @@
   }
 
   @Override
-  public boolean equals(final Object obj) {
+  public boolean equals(Object obj) {
     if (!super.equals(obj) || !(obj instanceof AccessSection)) {
       return false;
     }
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 d6ddddb..788a26d 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
@@ -31,7 +31,7 @@
    * <p>This constructor should only be a last-ditch effort, when the usual account lookup has
    * failed and a stale account id has been discovered in the data store.
    */
-  public AccountInfo(final Account.Id id) {
+  public AccountInfo(Account.Id id) {
     this.id = id;
   }
 
@@ -40,7 +40,7 @@
    *
    * @param a the data store record holding the specific account details.
    */
-  public AccountInfo(final Account a) {
+  public AccountInfo(Account a) {
     id = a.getId();
     fullName = a.getFullName();
     preferredEmail = a.getPreferredEmail();
@@ -66,7 +66,7 @@
     return preferredEmail;
   }
 
-  public void setPreferredEmail(final String email) {
+  public void setPreferredEmail(String email) {
     preferredEmail = email;
   }
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java
index 9c34c97..e0a6569 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/FilenameComparator.java
@@ -29,7 +29,7 @@
   private FilenameComparator() {}
 
   @Override
-  public int compare(final String path1, final String path2) {
+  public int compare(String path1, String path2) {
     if (Patch.COMMIT_MSG.equals(path1) && Patch.COMMIT_MSG.equals(path2)) {
       return 0;
     } else if (Patch.COMMIT_MSG.equals(path1)) {
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
index 4c9b64a..6fd0e77 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.common.data;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -115,6 +116,9 @@
 
   private static final List<String> NAMES_ALL;
   private static final List<String> NAMES_LC;
+  private static final String[] RANGE_NAMES = {
+    QUERY_LIMIT, BATCH_CHANGES_LIMIT,
+  };
 
   static {
     NAMES_ALL = new ArrayList<>();
@@ -158,7 +162,16 @@
 
   /** @return true if the capability should have a range attached. */
   public static boolean hasRange(String varName) {
-    return QUERY_LIMIT.equalsIgnoreCase(varName) || BATCH_CHANGES_LIMIT.equalsIgnoreCase(varName);
+    for (String n : RANGE_NAMES) {
+      if (n.equalsIgnoreCase(varName)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public static List<String> getRangeNames() {
+    return Collections.unmodifiableList(Arrays.asList(RANGE_NAMES));
   }
 
   /** @return the valid range for the capability if it has one, otherwise null. */
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
index 62a8544..c915cb9 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescription.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
 
 /** Group methods exposed by the GroupBackend. */
 public class GroupDescription {
@@ -42,10 +43,19 @@
     String getUrl();
   }
 
-  /** The extended information exposed by internal groups backed by an AccountGroup. */
+  /** The extended information exposed by internal groups. */
   public interface Internal extends Basic {
-    /** @return the backing AccountGroup. */
-    AccountGroup getAccountGroup();
+
+    AccountGroup.Id getId();
+
+    @Nullable
+    String getDescription();
+
+    AccountGroup.UUID getOwnerGroupUUID();
+
+    boolean isVisibleToAll();
+
+    Timestamp getCreatedOn();
   }
 
   private GroupDescription() {}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
index b8e498f..25493e8 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDescriptions.java
@@ -17,19 +17,12 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
 
 /** Utility class for building GroupDescription objects. */
 public class GroupDescriptions {
 
-  @Nullable
-  public static AccountGroup toAccountGroup(GroupDescription.Basic group) {
-    if (group instanceof GroupDescription.Internal) {
-      return ((GroupDescription.Internal) group).getAccountGroup();
-    }
-    return null;
-  }
-
-  public static GroupDescription.Internal forAccountGroup(final AccountGroup group) {
+  public static GroupDescription.Internal forAccountGroup(AccountGroup group) {
     return new GroupDescription.Internal() {
       @Override
       public AccountGroup.UUID getGroupUUID() {
@@ -42,21 +35,41 @@
       }
 
       @Override
-      public AccountGroup getAccountGroup() {
-        return group;
-      }
-
-      @Override
       @Nullable
       public String getEmailAddress() {
         return null;
       }
 
       @Override
-      @Nullable
       public String getUrl() {
         return "#" + PageLinks.toGroup(getGroupUUID());
       }
+
+      @Override
+      public AccountGroup.Id getId() {
+        return group.getId();
+      }
+
+      @Override
+      @Nullable
+      public String getDescription() {
+        return group.getDescription();
+      }
+
+      @Override
+      public AccountGroup.UUID getOwnerGroupUUID() {
+        return group.getOwnerGroupUUID();
+      }
+
+      @Override
+      public boolean isVisibleToAll() {
+        return group.isVisibleToAll();
+      }
+
+      @Override
+      public Timestamp getCreatedOn() {
+        return group.getCreatedOn();
+      }
     };
   }
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDetail.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDetail.java
deleted file mode 100644
index cf4cfcd..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupDetail.java
+++ /dev/null
@@ -1,40 +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.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import java.util.List;
-
-public class GroupDetail {
-  public AccountGroup group;
-  public List<AccountGroupMember> members;
-  public List<AccountGroupById> includes;
-
-  public GroupDetail() {}
-
-  public void setGroup(AccountGroup g) {
-    group = g;
-  }
-
-  public void setMembers(List<AccountGroupMember> m) {
-    members = m;
-  }
-
-  public void setIncludes(List<AccountGroupById> i) {
-    includes = i;
-  }
-}
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
index 1f746c4..2b5bf1b 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GroupInfo.java
@@ -31,7 +31,7 @@
    * <p>This constructor should only be a last-ditch effort, when the usual group lookup has failed
    * and a stale group id has been discovered in the data store.
    */
-  public GroupInfo(final AccountGroup.UUID uuid) {
+  public GroupInfo(AccountGroup.UUID uuid) {
     this.uuid = uuid;
   }
 
@@ -46,8 +46,7 @@
     url = a.getUrl();
 
     if (a instanceof GroupDescription.Internal) {
-      AccountGroup group = ((GroupDescription.Internal) a).getAccountGroup();
-      description = group.getDescription();
+      description = ((GroupDescription.Internal) a).getDescription();
     }
   }
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelFunction.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelFunction.java
new file mode 100644
index 0000000..0ce2c29
--- /dev/null
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelFunction.java
@@ -0,0 +1,71 @@
+// 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.common.data;
+
+import com.google.gerrit.common.Nullable;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Functions for determining submittability based on label votes.
+ *
+ * <p>Only describes built-in label functions. Admins can extend the logic arbitrarily using Prolog
+ * rules, in which case the choice of function in the project config is ignored.
+ *
+ * <p>Function semantics are documented in {@code config-labels.txt}, and actual behavior is
+ * implemented in Prolog in {@code gerrit_common.pl}.
+ */
+public enum LabelFunction {
+  MAX_WITH_BLOCK("MaxWithBlock", true),
+  ANY_WITH_BLOCK("AnyWithBlock", true),
+  MAX_NO_BLOCK("MaxNoBlock", false),
+  NO_BLOCK("NoBlock", false),
+  NO_OP("NoOp", false),
+  PATCH_SET_LOCK("PatchSetLock", false);
+
+  public static final Map<String, LabelFunction> ALL;
+
+  static {
+    Map<String, LabelFunction> all = new LinkedHashMap<>();
+    for (LabelFunction f : values()) {
+      all.put(f.getFunctionName(), f);
+    }
+    ALL = Collections.unmodifiableMap(all);
+  }
+
+  public static Optional<LabelFunction> parse(@Nullable String str) {
+    return Optional.ofNullable(ALL.get(str));
+  }
+
+  private final String name;
+  private final boolean isBlock;
+
+  private LabelFunction(String name, boolean isBlock) {
+    this.name = name;
+    this.isBlock = isBlock;
+  }
+
+  /** The function name as defined in documentation and {@code project.config}. */
+  public String getFunctionName() {
+    return name;
+  }
+
+  /** Whether the label is a "block" label, meaning a minimum vote will prevent submission. */
+  public boolean isBlock() {
+    return isBlock;
+  }
+}
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 6d427e7..7bfd22e 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import java.util.ArrayList;
@@ -22,6 +23,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 public class LabelType {
   public static final boolean DEF_ALLOW_POST_SUBMIT = true;
@@ -97,7 +99,9 @@
 
   protected String name;
 
+  // String rather than LabelFunction for backwards compatibility with GWT JSON interface.
   protected String functionName;
+
   protected boolean copyMinScore;
   protected boolean copyMaxScore;
   protected boolean copyAllScoresOnMergeFirstParentUpdate;
@@ -124,7 +128,7 @@
     values = sortValues(valueList);
     defaultValue = 0;
 
-    functionName = "MaxWithBlock";
+    functionName = LabelFunction.MAX_WITH_BLOCK.getFunctionName();
 
     maxNegative = Short.MIN_VALUE;
     maxPositive = Short.MAX_VALUE;
@@ -154,12 +158,19 @@
     return psa.getLabelId().get().equalsIgnoreCase(name);
   }
 
-  public String getFunctionName() {
-    return functionName;
+  public LabelFunction getFunction() {
+    if (functionName == null) {
+      return null;
+    }
+    Optional<LabelFunction> f = LabelFunction.parse(functionName);
+    if (!f.isPresent()) {
+      throw new IllegalStateException("Unsupported functionName: " + functionName);
+    }
+    return f.get();
   }
 
-  public void setFunctionName(String functionName) {
-    this.functionName = functionName;
+  public void setFunction(@Nullable LabelFunction function) {
+    this.functionName = function != null ? function.getFunctionName() : null;
   }
 
   public boolean canOverride() {
@@ -274,7 +285,7 @@
     return byValue.get(value);
   }
 
-  public LabelValue getValue(final PatchSetApproval ca) {
+  public LabelValue getValue(PatchSetApproval ca) {
     initByValue();
     return byValue.get(ca.getValue());
   }
@@ -282,7 +293,7 @@
   private void initByValue() {
     if (byValue == null) {
       byValue = new HashMap<>();
-      for (final LabelValue v : values) {
+      for (LabelValue v : values) {
         byValue.put(v.getValue(), v);
       }
     }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java
index e76db30..d5891d1 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/LabelTypes.java
@@ -29,7 +29,7 @@
 
   protected LabelTypes() {}
 
-  public LabelTypes(final List<? extends LabelType> approvals) {
+  public LabelTypes(List<? extends LabelType> approvals) {
     labelTypes = Collections.unmodifiableList(new ArrayList<>(approvals));
   }
 
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java
index 93b7f90..28e47ee 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/ParameterizedString.java
@@ -24,7 +24,7 @@
 /** Performs replacements on strings such as <code>Hello ${user}</code>. */
 public class ParameterizedString {
   /** Obtain a string which has no parameters and always produces the value. */
-  public static ParameterizedString asis(final String constant) {
+  public static ParameterizedString asis(String constant) {
     return new ParameterizedString(new Constant(constant));
   }
 
@@ -37,14 +37,14 @@
     this(new Constant(""));
   }
 
-  private ParameterizedString(final Constant c) {
+  private ParameterizedString(Constant c) {
     pattern = c.text;
     rawPattern = c.text;
     patternOps = Collections.<Format>singletonList(c);
     parameters = Collections.emptyList();
   }
 
-  public ParameterizedString(final String pattern) {
+  public ParameterizedString(String pattern) {
     final StringBuilder raw = new StringBuilder();
     final List<Parameter> prs = new ArrayList<>(4);
     final List<Format> ops = new ArrayList<>(4);
@@ -103,7 +103,7 @@
   }
 
   /** Convert a map of parameters into a value array for binding. */
-  public String[] bind(final Map<String, String> params) {
+  public String[] bind(Map<String, String> params) {
     final String[] r = new String[parameters.size()];
     for (int i = 0; i < r.length; i++) {
       final StringBuilder b = new StringBuilder();
@@ -114,15 +114,15 @@
   }
 
   /** Format this string by performing the variable replacements. */
-  public String replace(final Map<String, String> params) {
+  public String replace(Map<String, String> params) {
     final StringBuilder r = new StringBuilder();
-    for (final Format f : patternOps) {
+    for (Format f : patternOps) {
       f.format(r, params);
     }
     return r.toString();
   }
 
-  public Builder replace(final String name, final String value) {
+  public Builder replace(String name, String value) {
     return new Builder().replace(name, value);
   }
 
@@ -134,7 +134,7 @@
   public final class Builder {
     private final Map<String, String> params = new HashMap<>();
 
-    public Builder replace(final String name, final String value) {
+    public Builder replace(String name, String value) {
       params.put(name, value);
       return this;
     }
@@ -152,7 +152,7 @@
   private static class Constant extends Format {
     private final String text;
 
-    Constant(final String text) {
+    Constant(String text) {
       this.text = text;
     }
 
@@ -166,7 +166,7 @@
     private final String name;
     private final List<Function> functions;
 
-    Parameter(final String parameter) {
+    Parameter(String parameter) {
       // "parameter[.functions...]" -> (parameter, functions...)
       final List<String> names = Arrays.asList(parameter.split("\\."));
       final List<Function> functs = new ArrayList<>(names.size());
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 172be09..3428580 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
@@ -16,12 +16,12 @@
 
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.prettify.common.EditList;
 import com.google.gerrit.prettify.common.SparseFileContent;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
 
 public class PatchScript {
@@ -48,6 +48,7 @@
   private SparseFileContent a;
   private SparseFileContent b;
   private List<Edit> edits;
+  private Set<Edit> editsDueToRebase;
   private DisplayMethod displayMethodA;
   private DisplayMethod displayMethodB;
   private transient String mimeTypeA;
@@ -63,30 +64,31 @@
   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,
-      final List<String> h,
-      final DiffPreferencesInfo dp,
-      final SparseFileContent ca,
-      final SparseFileContent cb,
-      final List<Edit> e,
-      final DisplayMethod ma,
-      final DisplayMethod mb,
-      final String mta,
-      final String mtb,
-      final CommentDetail cd,
-      final List<Patch> hist,
-      final boolean hf,
-      final boolean id,
-      final boolean idf,
-      final boolean idt,
+      Change.Key ck,
+      ChangeType ct,
+      String on,
+      String nn,
+      FileMode om,
+      FileMode nm,
+      List<String> h,
+      DiffPreferencesInfo dp,
+      SparseFileContent ca,
+      SparseFileContent cb,
+      List<Edit> e,
+      Set<Edit> editsDueToRebase,
+      DisplayMethod ma,
+      DisplayMethod mb,
+      String mta,
+      String mtb,
+      CommentDetail cd,
+      List<Patch> hist,
+      boolean hf,
+      boolean id,
+      boolean idf,
+      boolean idt,
       boolean bin,
-      final String cma,
-      final String cmb) {
+      String cma,
+      String cmb) {
     changeId = ck;
     changeType = ct;
     oldName = on;
@@ -98,6 +100,7 @@
     a = ca;
     b = cb;
     edits = e;
+    this.editsDueToRebase = editsDueToRebase;
     displayMethodA = ma;
     displayMethodB = mb;
     mimeTypeA = mta;
@@ -211,12 +214,8 @@
     return edits;
   }
 
-  public Iterable<EditList.Hunk> getHunks() {
-    int ctx = diffPrefs.context;
-    if (ctx == DiffPreferencesInfo.WHOLE_FILE_CONTEXT) {
-      ctx = Math.max(a.size(), b.size());
-    }
-    return new EditList(edits, ctx, a.size(), b.size()).getHunks();
+  public Set<Edit> getEditsDueToRebase() {
+    return editsDueToRebase;
   }
 
   public boolean isBinary() {
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 30bd089..4910424 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
@@ -28,7 +28,6 @@
   public static final String CREATE_TAG = "createTag";
   public static final String CREATE_SIGNED_TAG = "createSignedTag";
   public static final String DELETE_CHANGES = "deleteChanges";
-  public static final String DELETE_DRAFTS = "deleteDrafts";
   public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
   public static final String EDIT_HASHTAGS = "editHashtags";
   public static final String EDIT_ASSIGNEE = "editAssignee";
@@ -39,7 +38,6 @@
   public static final String LABEL = "label-";
   public static final String LABEL_AS = "labelAs-";
   public static final String OWNER = "owner";
-  public static final String PUBLISH_DRAFTS = "publishDrafts";
   public static final String PUSH = "push";
   public static final String PUSH_MERGE = "pushMerge";
   public static final String READ = "read";
@@ -47,7 +45,7 @@
   public static final String REMOVE_REVIEWER = "removeReviewer";
   public static final String SUBMIT = "submit";
   public static final String SUBMIT_AS = "submitAs";
-  public static final String VIEW_DRAFTS = "viewDrafts";
+  public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
 
   private static final List<String> NAMES_LC;
   private static final int LABEL_INDEX;
@@ -74,14 +72,12 @@
     NAMES_LC.add(REMOVE_REVIEWER.toLowerCase());
     NAMES_LC.add(SUBMIT.toLowerCase());
     NAMES_LC.add(SUBMIT_AS.toLowerCase());
-    NAMES_LC.add(VIEW_DRAFTS.toLowerCase());
+    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
     NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
     NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
-    NAMES_LC.add(DELETE_DRAFTS.toLowerCase());
     NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
     NAMES_LC.add(DELETE_CHANGES.toLowerCase());
-    NAMES_LC.add(PUBLISH_DRAFTS.toLowerCase());
 
     LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
     LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
@@ -262,7 +258,7 @@
   }
 
   @Override
-  public boolean equals(final Object obj) {
+  public boolean equals(Object obj) {
     if (!(obj instanceof Permission)) {
       return false;
     }
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 9098ec3..c50af5c 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
@@ -277,7 +277,7 @@
   }
 
   @Override
-  public boolean equals(final Object obj) {
+  public boolean equals(Object obj) {
     if (!(obj instanceof PermissionRule)) {
       return false;
     }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java
index f8aa6a0..663379a 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/RefConfigSection.java
@@ -46,7 +46,7 @@
   }
 
   @Override
-  public boolean equals(final Object obj) {
+  public boolean equals(Object obj) {
     if (!(obj instanceof RefConfigSection)) {
       return false;
     }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java
index bac9294..05f1611 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/SshHostKey.java
@@ -22,7 +22,7 @@
 
   protected SshHostKey() {}
 
-  public SshHostKey(final String hi, final String hk, final String fp) {
+  public SshHostKey(String hi, String hk, String fp) {
     hostIdent = hi;
     hostKey = hk;
     fingerprint = fp;
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 8b740c3..6e3db9e 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
@@ -22,23 +22,23 @@
 
   public static final String MESSAGE = "Group Not Found: ";
 
-  public NoSuchGroupException(final AccountGroup.Id key) {
+  public NoSuchGroupException(AccountGroup.Id key) {
     this(key, null);
   }
 
-  public NoSuchGroupException(final AccountGroup.UUID key) {
+  public NoSuchGroupException(AccountGroup.UUID key) {
     this(key, null);
   }
 
-  public NoSuchGroupException(final AccountGroup.Id key, final Throwable why) {
+  public NoSuchGroupException(AccountGroup.Id key, Throwable why) {
     super(MESSAGE + key.toString(), why);
   }
 
-  public NoSuchGroupException(final AccountGroup.UUID key, final Throwable why) {
+  public NoSuchGroupException(AccountGroup.UUID key, Throwable why) {
     super(MESSAGE + key.toString(), why);
   }
 
-  public NoSuchGroupException(final AccountGroup.NameKey k, final Throwable why) {
+  public NoSuchGroupException(AccountGroup.NameKey k, Throwable why) {
     super(MESSAGE + k.toString(), why);
   }
 
@@ -46,7 +46,7 @@
     this(who, null);
   }
 
-  public NoSuchGroupException(String who, final Throwable why) {
+  public NoSuchGroupException(String who, Throwable why) {
     super(MESSAGE + who, why);
   }
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java b/gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
index ec8a811..16d5240 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/errors/UpdateParentFailedException.java
@@ -20,7 +20,7 @@
 
   public static final String MESSAGE = "Update Parent Project Failed: ";
 
-  public UpdateParentFailedException(final String message, final Throwable why) {
+  public UpdateParentFailedException(String message, Throwable why) {
     super(MESSAGE + ": " + message, why);
   }
 }
diff --git a/gerrit-elasticsearch/BUILD b/gerrit-elasticsearch/BUILD
index bbf413b..cc9e4f3 100644
--- a/gerrit-elasticsearch/BUILD
+++ b/gerrit-elasticsearch/BUILD
@@ -3,15 +3,13 @@
 
 java_library(
     name = "elasticsearch",
-    srcs = glob(
-        ["src/main/java/**/*.java"],
-        exclude = ["**/testing/**"],
-    ),
+    srcs = glob(["src/main/java/**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//gerrit-antlr:query_exception",
         "//gerrit-common:annotations",
         "//gerrit-extension-api:api",
+        "//gerrit-index:index",
+        "//gerrit-index:query_exception",
         "//gerrit-reviewdb:server",
         "//gerrit-server:server",
         "//lib:gson",
@@ -34,16 +32,22 @@
 java_library(
     name = "elasticsearch_test_utils",
     testonly = 1,
-    srcs = glob(["src/main/java/com/google/gerrit/elasticsearch/testing/*.java"]),
+    srcs = glob([
+        "src/test/java/**/ElasticTestUtils.java",
+        "src/test/java/**/ElasticContainer.java",
+    ]),
     visibility = ["//visibility:public"],
     deps = [
         ":elasticsearch",
+        "//gerrit-index:index",
+        "//gerrit-reviewdb:server",
         "//gerrit-server:server",
         "//lib:truth",
         "//lib/guice",
         "//lib/httpcomponents:httpcore",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/testcontainers",
+        "//lib/testcontainers:testcontainers-elasticsearch",
     ],
 )
 
@@ -65,12 +69,12 @@
 
 SUFFIX = "sTest.java"
 
-ELASTICSEARCH_TESTS = {i: "src/test/java/com/google/gerrit/elasticsearch/ElasticQuery" + i.capitalize() + SUFFIX for i in TYPES}
-
 ELASTICSEARCH_TESTS_V5 = {i: "src/test/java/com/google/gerrit/elasticsearch/ElasticV5Query" + i.capitalize() + SUFFIX for i in TYPES}
 
 ELASTICSEARCH_TESTS_V6 = {i: "src/test/java/com/google/gerrit/elasticsearch/ElasticV6Query" + i.capitalize() + SUFFIX for i in TYPES}
 
+ELASTICSEARCH_TESTS_V7 = {i: "src/test/java/com/google/gerrit/elasticsearch/ElasticV7Query" + i.capitalize() + SUFFIX for i in TYPES}
+
 ELASTICSEARCH_TAGS = [
     "docker",
     "elastic",
@@ -78,14 +82,6 @@
 ]
 
 [junit_tests(
-    name = "elasticsearch_query_%ss_test" % name,
-    size = "large",
-    srcs = [src],
-    tags = ELASTICSEARCH_TAGS,
-    deps = ELASTICSEARCH_DEPS,
-) for name, src in ELASTICSEARCH_TESTS.items()]
-
-[junit_tests(
     name = "elasticsearch_query_%ss_test_v5" % name,
     size = "large",
     srcs = [src],
@@ -101,6 +97,17 @@
     deps = ELASTICSEARCH_DEPS,
 ) for name, src in ELASTICSEARCH_TESTS_V6.items()]
 
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_v7" % name,
+    size = "large",
+    srcs = [src],
+    tags = ELASTICSEARCH_TAGS,
+    deps = ELASTICSEARCH_DEPS + [
+        "//lib/httpcomponents:httpasyncclient",
+        "//lib/httpcomponents:httpclient",
+    ],
+) for name, src in ELASTICSEARCH_TESTS_V7.items()]
+
 junit_tests(
     name = "elasticsearch_tests",
     size = "small",
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 72cc3d0..a5db030 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
 import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.Schema;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.gson.JsonArray;
@@ -82,6 +82,7 @@
     return content;
   }
 
+  private final ElasticConfiguration config;
   private final Schema<V> schema;
   private final SitePaths sitePaths;
   private final String indexNameRaw;
@@ -93,17 +94,18 @@
   protected final ElasticQueryBuilder queryBuilder;
 
   AbstractElasticIndex(
-      ElasticConfiguration cfg,
+      ElasticConfiguration config,
       SitePaths sitePaths,
       Schema<V> schema,
       ElasticRestClientProvider client,
       String indexName,
       String indexType) {
+    this.config = config;
     this.sitePaths = sitePaths;
     this.schema = schema;
     this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
     this.queryBuilder = new ElasticQueryBuilder();
-    this.indexName = cfg.getIndexName(indexName, schema.getVersion());
+    this.indexName = config.getIndexName(indexName, schema.getVersion());
     this.indexNameRaw = indexName;
     this.client = client;
     this.type = client.adapter().getType(indexType);
@@ -147,7 +149,7 @@
   @Override
   public void deleteAll() throws IOException {
     // Delete the index, if it exists.
-    String endpoint = indexName + client.adapter().indicesExistParam();
+    String endpoint = indexName + client.adapter().indicesExistParams();
     Response response = performRequest("HEAD", endpoint);
     int statusCode = response.getStatusLine().getStatusCode();
     if (statusCode == HttpStatus.SC_OK) {
@@ -160,8 +162,10 @@
     }
 
     // Recreate the index.
-    String indexCreationFields = concatJsonString(getSettings(), getMappings());
-    response = performRequest("PUT", indexName, indexCreationFields);
+    String indexCreationFields = concatJsonString(getSettings(client.adapter()), getMappings());
+    response =
+        performRequest(
+            "PUT", indexName + client.adapter().includeTypeNameParam(), indexCreationFields);
     statusCode = response.getStatusLine().getStatusCode();
     if (statusCode != HttpStatus.SC_OK) {
       String error = String.format("Failed to create index %s: %s", indexName, statusCode);
@@ -173,8 +177,8 @@
 
   protected abstract String getMappings();
 
-  private String getSettings() {
-    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting()));
+  private String getSettings(ElasticQueryAdapter adapter) {
+    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting(config, adapter)));
   }
 
   protected abstract String getId(V v);
@@ -184,10 +188,15 @@
   }
 
   protected String getMappingsFor(String type, MappingProperties properties) {
-    JsonObject mappingType = new JsonObject();
-    mappingType.add(type, gson.toJsonTree(properties));
     JsonObject mappings = new JsonObject();
-    mappings.add(MAPPINGS, gson.toJsonTree(mappingType));
+
+    if (client.adapter().omitType()) {
+      mappings.add(MAPPINGS, gson.toJsonTree(properties));
+    } else {
+      JsonObject mappingType = new JsonObject();
+      mappingType.add(type, gson.toJsonTree(properties));
+      mappings.add(MAPPINGS, gson.toJsonTree(mappingType));
+    }
     return gson.toJson(mappings);
   }
 
@@ -216,7 +225,6 @@
   protected JsonArray getSortArray(String idFieldName) {
     JsonObject properties = new JsonObject();
     properties.addProperty(ORDER, "asc");
-    client.adapter().setIgnoreUnmapped(properties);
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(idFieldName, properties, sortArray);
@@ -224,9 +232,13 @@
   }
 
   protected String getURI(String type, String request) throws UnsupportedEncodingException {
-    String encodedType = URLEncoder.encode(type, UTF_8.toString());
     String encodedIndexName = URLEncoder.encode(indexName, UTF_8.toString());
-    return encodedIndexName + "/" + encodedType + "/" + request;
+    if (SEARCH.equals(request) && client.adapter().omitType()) {
+      return encodedIndexName + "/" + request;
+    }
+    String encodedTypeIfAny =
+        client.adapter().omitType() ? "" : "/" + URLEncoder.encode(type, UTF_8.toString());
+    return encodedIndexName + encodedTypeIfAny + "/" + request;
   }
 
   protected Response postRequest(String uri, Object payload) throws IOException {
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index aee4177..722e6d8 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -23,18 +23,18 @@
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 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.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.account.AccountField;
 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.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 9b845e0..3cb56f4 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.index.change.ChangeField.APPROVAL_CODEC;
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE_CODEC;
 import static com.google.gerrit.server.index.change.ChangeField.PATCH_SET_CODEC;
@@ -36,23 +37,23 @@
 import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.QueryOptions;
-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.ChangeIndexRewriter;
 import com.google.gerrit.server.project.SubmitRuleOptions;
-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.gson.JsonArray;
@@ -61,9 +62,9 @@
 import com.google.gson.JsonParser;
 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.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.util.Collections;
@@ -78,7 +79,7 @@
 import org.slf4j.LoggerFactory;
 
 /** Secondary index implementation using Elasticsearch. */
-public class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
+class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
     implements ChangeIndex {
   private static final Logger log = LoggerFactory.getLogger(ElasticChangeIndex.class);
 
@@ -102,22 +103,19 @@
   private final ChangeMapping mapping;
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
-  private final FillArgs fillArgs;
   private final Schema<ChangeData> schema;
 
-  @AssistedInject
+  @Inject
   ElasticChangeIndex(
       ElasticConfiguration cfg,
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
-      FillArgs fillArgs,
       SitePaths sitePaths,
       ElasticRestClientProvider client,
       @Assisted Schema<ChangeData> schema) {
     super(cfg, sitePaths, schema, client, CHANGES, ALL_CHANGES);
     this.db = db;
     this.changeDataFactory = changeDataFactory;
-    this.fillArgs = fillArgs;
     this.schema = schema;
     this.mapping = new ChangeMapping(schema, client.adapter());
   }
@@ -142,8 +140,8 @@
     ElasticQueryAdapter adapter = client.adapter();
     BulkRequest bulk =
         new IndexRequest(getId(cd), indexName, adapter.getType(insertIndex), adapter)
-            .add(new UpdateRequest<>(fillArgs, schema, cd));
-    if (!adapter.usePostV5Type()) {
+            .add(new UpdateRequest<>(schema, cd));
+    if (adapter.deleteToReplace()) {
       bulk.add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex, adapter));
     }
 
@@ -162,17 +160,19 @@
       throws QueryParseException {
     Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
     List<String> indexes = Lists.newArrayListWithCapacity(2);
-    if (client.adapter().usePostV5Type()) {
-      if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()
-          || !Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
-        indexes.add(ElasticQueryAdapter.POST_V5_TYPE);
-      }
-    } else {
-      if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
-        indexes.add(OPEN_CHANGES);
-      }
-      if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
-        indexes.add(CLOSED_CHANGES);
+    if (!client.adapter().omitType()) {
+      if (client.adapter().useV6Type()) {
+        if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()
+            || !Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+          indexes.add(ElasticQueryAdapter.V6_TYPE);
+        }
+      } else {
+        if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
+          indexes.add(OPEN_CHANGES);
+        }
+        if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+          indexes.add(CLOSED_CHANGES);
+        }
       }
     }
     return new QuerySource(indexes, p, opts);
@@ -180,16 +180,16 @@
 
   @Override
   protected String getDeleteActions(Id c) {
-    if (client.adapter().usePostV5Type()) {
-      return delete(ElasticQueryAdapter.POST_V5_TYPE, c);
+    if (!client.adapter().useV5Type()) {
+      return delete(client.adapter().getType(), c);
     }
     return delete(OPEN_CHANGES, c) + delete(CLOSED_CHANGES, c);
   }
 
   @Override
   protected String getMappings() {
-    if (client.adapter().usePostV5Type()) {
-      return getMappingsFor(ElasticQueryAdapter.POST_V5_TYPE, mapping.changes);
+    if (!client.adapter().useV5Type()) {
+      return getMappingsFor(client.adapter().getType(), mapping.changes);
     }
     return gson.toJson(ImmutableMap.of(MAPPINGS, mapping));
   }
@@ -282,10 +282,8 @@
 
       if (c == null) {
         int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
-        String projectName = source.get(ChangeField.PROJECT.getName()).getAsString();
-        if (projectName == null) {
-          return changeDataFactory.createOnlyWhenNoteDbDisabled(db.get(), new Change.Id(id));
-        }
+        // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
+        String projectName = checkNotNull(source.get(ChangeField.PROJECT.getName()).getAsString());
         return changeDataFactory.create(
             db.get(), new Project.NameKey(projectName), new Change.Id(id));
       }
@@ -368,6 +366,37 @@
         cd.setReviewers(ReviewerSet.empty());
       }
 
+      if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
+        cd.setReviewersByEmail(
+            ChangeField.parseReviewerByEmailFieldValues(
+                FluentIterable.from(
+                        source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                    .transform(JsonElement::getAsString)));
+      } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
+        cd.setReviewersByEmail(ReviewerByEmailSet.empty());
+      }
+
+      if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
+        cd.setPendingReviewers(
+            ChangeField.parseReviewerFieldValues(
+                FluentIterable.from(
+                        source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
+                    .transform(JsonElement::getAsString)));
+      } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
+        cd.setPendingReviewers(ReviewerSet.empty());
+      }
+
+      if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
+        cd.setPendingReviewersByEmail(
+            ChangeField.parseReviewerByEmailFieldValues(
+                FluentIterable.from(
+                        source
+                            .get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())
+                            .getAsJsonArray())
+                    .transform(JsonElement::getAsString)));
+      } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
+        cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
+      }
       decodeSubmitRecords(
           source,
           ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
@@ -422,7 +451,6 @@
     private JsonArray getSortArray() {
       JsonObject properties = new JsonObject();
       properties.addProperty(ORDER, "desc");
-      client.adapter().setIgnoreUnmapped(properties);
 
       JsonArray sortArray = new JsonArray();
       addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index dce28019..4ec5feb 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -25,7 +25,6 @@
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.TimeUnit;
 import org.apache.http.HttpHost;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
@@ -38,20 +37,22 @@
   static final String SECTION_ELASTICSEARCH = "elasticsearch";
   static final String KEY_PASSWORD = "password";
   static final String KEY_USERNAME = "username";
-  static final String KEY_MAX_RETRY_TIMEOUT = "maxRetryTimeout";
   static final String KEY_PREFIX = "prefix";
   static final String KEY_SERVER = "server";
+  static final String KEY_NUMBER_OF_SHARDS = "numberOfShards";
+  static final String KEY_NUMBER_OF_REPLICAS = "numberOfReplicas";
   static final String DEFAULT_PORT = "9200";
   static final String DEFAULT_USERNAME = "elastic";
-  static final int DEFAULT_MAX_RETRY_TIMEOUT_MS = 30000;
-  static final TimeUnit MAX_RETRY_TIMEOUT_UNIT = TimeUnit.MILLISECONDS;
+  static final int DEFAULT_NUMBER_OF_SHARDS = 0;
+  static final int DEFAULT_NUMBER_OF_REPLICAS = 1;
 
   private final Config cfg;
   private final List<HttpHost> hosts;
 
   final String username;
   final String password;
-  final int maxRetryTimeout;
+  final int numberOfShards;
+  final int numberOfReplicas;
   final String prefix;
 
   @Inject
@@ -63,15 +64,11 @@
             ? null
             : firstNonNull(
                 cfg.getString(SECTION_ELASTICSEARCH, null, KEY_USERNAME), DEFAULT_USERNAME);
-    this.maxRetryTimeout =
-        (int)
-            cfg.getTimeUnit(
-                SECTION_ELASTICSEARCH,
-                null,
-                KEY_MAX_RETRY_TIMEOUT,
-                DEFAULT_MAX_RETRY_TIMEOUT_MS,
-                MAX_RETRY_TIMEOUT_UNIT);
     this.prefix = Strings.nullToEmpty(cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PREFIX));
+    this.numberOfShards =
+        cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_SHARDS, DEFAULT_NUMBER_OF_SHARDS);
+    this.numberOfReplicas =
+        cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_REPLICAS, DEFAULT_NUMBER_OF_REPLICAS);
     this.hosts = new ArrayList<>();
     for (String server : cfg.getStringList(SECTION_ELASTICSEARCH, null, KEY_SERVER)) {
       try {
@@ -104,4 +101,11 @@
   String getIndexName(String name, int schemaVersion) {
     return String.format("%s%s_%04d", prefix, name, schemaVersion);
   }
+
+  int getNumberOfShards(ElasticQueryAdapter adapter) {
+    if (numberOfShards == DEFAULT_NUMBER_OF_SHARDS) {
+      return adapter.getDefaultNumberOfShards();
+    }
+    return numberOfShards;
+  }
 }
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index c01f4b4..79701e1 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -21,17 +21,18 @@
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gerrit.server.query.DataSource;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
@@ -45,6 +46,7 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.http.HttpStatus;
 import org.apache.http.StatusLine;
@@ -52,14 +54,14 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, AccountGroup>
+public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
     implements GroupIndex {
   private static final Logger log = LoggerFactory.getLogger(ElasticGroupIndex.class);
 
   static class GroupMapping {
     final MappingProperties groups;
 
-    GroupMapping(Schema<AccountGroup> schema, ElasticQueryAdapter adapter) {
+    GroupMapping(Schema<InternalGroup> schema, ElasticQueryAdapter adapter) {
       this.groups = ElasticMapping.createMapping(schema, adapter);
     }
   }
@@ -68,7 +70,7 @@
 
   private final GroupMapping mapping;
   private final Provider<GroupCache> groupCache;
-  private final Schema<AccountGroup> schema;
+  private final Schema<InternalGroup> schema;
 
   @AssistedInject
   ElasticGroupIndex(
@@ -76,7 +78,7 @@
       SitePaths sitePaths,
       Provider<GroupCache> groupCache,
       ElasticRestClientProvider client,
-      @Assisted Schema<AccountGroup> schema) {
+      @Assisted Schema<InternalGroup> schema) {
     super(cfg, sitePaths, schema, client, GROUPS);
     this.groupCache = groupCache;
     this.mapping = new GroupMapping(schema, client.adapter());
@@ -84,7 +86,7 @@
   }
 
   @Override
-  public void replace(AccountGroup group) throws IOException {
+  public void replace(InternalGroup group) throws IOException {
     BulkRequest bulk =
         new IndexRequest(getId(group), indexName, type, client.adapter())
             .add(new UpdateRequest<>(schema, group));
@@ -101,7 +103,7 @@
   }
 
   @Override
-  public DataSource<AccountGroup> getSource(Predicate<AccountGroup> p, QueryOptions opts)
+  public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
       throws QueryParseException {
     return new QuerySource(p, opts);
   }
@@ -117,15 +119,15 @@
   }
 
   @Override
-  protected String getId(AccountGroup group) {
+  protected String getId(InternalGroup group) {
     return group.getGroupUUID().get();
   }
 
-  private class QuerySource implements DataSource<AccountGroup> {
+  private class QuerySource implements DataSource<InternalGroup> {
     private final String search;
     private final Set<String> fields;
 
-    QuerySource(Predicate<AccountGroup> p, QueryOptions opts) throws QueryParseException {
+    QuerySource(Predicate<InternalGroup> p, QueryOptions opts) throws QueryParseException {
       QueryBuilder qb = queryBuilder.toQueryBuilder(p);
       fields = IndexUtils.groupFields(opts);
       SearchSourceBuilder searchSource =
@@ -145,9 +147,9 @@
     }
 
     @Override
-    public ResultSet<AccountGroup> read() throws OrmException {
+    public ResultSet<InternalGroup> read() throws OrmException {
       try {
-        List<AccountGroup> results = Collections.emptyList();
+        List<InternalGroup> results = Collections.emptyList();
         String uri = getURI(type, SEARCH);
         Response response = postRequest(uri, search);
         StatusLine statusLine = response.getStatusLine();
@@ -159,21 +161,21 @@
             JsonArray json = obj.getAsJsonArray("hits");
             results = Lists.newArrayListWithCapacity(json.size());
             for (int i = 0; i < json.size(); i++) {
-              results.add(toAccountGroup(json.get(i)));
+              results.add(toAccountGroup(json.get(i)).get());
             }
           }
         } else {
           log.error(statusLine.getReasonPhrase());
         }
-        final List<AccountGroup> r = Collections.unmodifiableList(results);
-        return new ResultSet<AccountGroup>() {
+        final List<InternalGroup> r = Collections.unmodifiableList(results);
+        return new ResultSet<InternalGroup>() {
           @Override
-          public Iterator<AccountGroup> iterator() {
+          public Iterator<InternalGroup> iterator() {
             return r.iterator();
           }
 
           @Override
-          public List<AccountGroup> toList() {
+          public List<InternalGroup> toList() {
             return r;
           }
 
@@ -187,7 +189,7 @@
       }
     }
 
-    private AccountGroup toAccountGroup(JsonElement json) {
+    private Optional<InternalGroup> toAccountGroup(JsonElement json) {
       JsonElement source = json.getAsJsonObject().get("_source");
       if (source == null) {
         source = json.getAsJsonObject().get("fields");
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index 3d63f3e..6bc51ce 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.gerrit.server.index.AbstractIndexModule;
-import com.google.gerrit.server.index.AbstractVersionManager;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
@@ -28,7 +28,7 @@
     return new ElasticIndexModule(versions, threads);
   }
 
-  public static ElasticIndexModule latestVersionWithOnlineUpgrade() {
+  public static ElasticIndexModule latestVersion() {
     return new ElasticIndexModule(null, 0);
   }
 
@@ -58,7 +58,7 @@
   }
 
   @Override
-  protected Class<? extends AbstractVersionManager> getVersionManager() {
+  protected Class<? extends VersionManager> getVersionManager() {
     return ElasticIndexVersionManager.class;
   }
 }
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
index 42b9110..cff1911 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.common.primitives.Ints;
-import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.AbstractVersionManager;
 import com.google.gerrit.server.index.GerritIndexStatus;
-import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexDefinition;
-import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.OnlineUpgradeListener;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -32,8 +33,7 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class ElasticIndexVersionManager extends AbstractVersionManager
-    implements LifecycleListener {
+public class ElasticIndexVersionManager extends VersionManager {
   private static final Logger log = LoggerFactory.getLogger(ElasticIndexVersionManager.class);
 
   private final String prefix;
@@ -43,9 +43,10 @@
   ElasticIndexVersionManager(
       ElasticConfiguration cfg,
       SitePaths sitePaths,
+      DynamicSet<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs,
       ElasticIndexVersionDiscovery versionDiscovery) {
-    super(cfg.getConfig(), sitePaths, defs);
+    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg.getConfig()));
     this.versionDiscovery = versionDiscovery;
     prefix = cfg.prefix;
   }
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
index 9fcbaab..f8c4168 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.FieldType;
-import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.FieldType;
+import com.google.gerrit.index.Schema;
 import java.util.Map;
 
 class ElasticMapping {
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
index b52499b..dfb1cbf 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
@@ -14,56 +14,46 @@
 
 package com.google.gerrit.elasticsearch;
 
+import static com.google.gerrit.elasticsearch.ElasticVersion.V6_7;
+
 import com.google.gson.JsonObject;
 
 public class ElasticQueryAdapter {
-  static final String POST_V5_TYPE = "_doc";
+  static final String V6_TYPE = "_doc";
 
-  private final boolean ignoreUnmapped;
-  private final boolean usePostV5Type;
+  private static final String INCLUDE_TYPE = "include_type_name=true";
+  private static final String INDICES = "?allow_no_indices=false";
+
+  private final boolean useV5Type;
+  private final boolean useV6Type;
+  private final boolean omitType;
+  private final int defaultNumberOfShards;
 
   private final String searchFilteringName;
-  private final String indicesExistParam;
+  private final String indicesExistParams;
   private final String exactFieldType;
   private final String stringFieldType;
   private final String indexProperty;
   private final String versionDiscoveryUrl;
+  private final String includeTypeNameParam;
 
   ElasticQueryAdapter(ElasticVersion version) {
-    this.ignoreUnmapped = version == ElasticVersion.V2_4;
-    this.usePostV5Type = version.isV6();
-    this.versionDiscoveryUrl = version.isV6() ? "/%s*" : "/%s*/_aliases";
-
-    switch (version) {
-      case V5_6:
-      case V6_2:
-      case V6_3:
-      case V6_4:
-        this.searchFilteringName = "_source";
-        this.indicesExistParam = "?allow_no_indices=false";
-        this.exactFieldType = "keyword";
-        this.stringFieldType = "text";
-        this.indexProperty = "true";
-        break;
-      case V2_4:
-      default:
-        this.searchFilteringName = "fields";
-        this.indicesExistParam = "";
-        this.exactFieldType = "string";
-        this.stringFieldType = "string";
-        this.indexProperty = "not_analyzed";
-        break;
-    }
-  }
-
-  void setIgnoreUnmapped(JsonObject properties) {
-    if (ignoreUnmapped) {
-      properties.addProperty("ignore_unmapped", true);
-    }
+    this.useV5Type = !version.isV6OrLater();
+    this.useV6Type = version.isV6();
+    this.omitType = version.isV7OrLater();
+    this.defaultNumberOfShards = version.isV7OrLater() ? 1 : 5;
+    this.versionDiscoveryUrl = version.isV6OrLater() ? "/%s*" : "/%s*/_aliases";
+    this.searchFilteringName = "_source";
+    this.indicesExistParams =
+        version.isAtLeastMinorVersion(V6_7) ? INDICES + "&" + INCLUDE_TYPE : INDICES;
+    this.exactFieldType = "keyword";
+    this.stringFieldType = "text";
+    this.indexProperty = "true";
+    this.includeTypeNameParam = version.isAtLeastMinorVersion(V6_7) ? "?" + INCLUDE_TYPE : "";
   }
 
   public void setType(JsonObject properties, String type) {
-    if (!usePostV5Type) {
+    if (useV5Type) {
       properties.addProperty("_type", type);
     }
   }
@@ -72,8 +62,8 @@
     return searchFilteringName;
   }
 
-  String indicesExistParam() {
-    return indicesExistParam;
+  String indicesExistParams() {
+    return indicesExistParams;
   }
 
   String exactFieldType() {
@@ -88,15 +78,42 @@
     return indexProperty;
   }
 
-  boolean usePostV5Type() {
-    return usePostV5Type;
+  boolean deleteToReplace() {
+    return useV5Type;
   }
 
-  String getType(String preV6Type) {
-    return usePostV5Type() ? POST_V5_TYPE : preV6Type;
+  boolean useV5Type() {
+    return useV5Type;
+  }
+
+  boolean useV6Type() {
+    return useV6Type;
+  }
+
+  boolean omitType() {
+    return omitType;
+  }
+
+  int getDefaultNumberOfShards() {
+    return defaultNumberOfShards;
+  }
+
+  String getType() {
+    return getType("");
+  }
+
+  String getType(String type) {
+    if (useV6Type()) {
+      return V6_TYPE;
+    }
+    return useV5Type() ? type : "";
   }
 
   String getVersionDiscoveryUrl(String name) {
     return String.format(versionDiscoveryUrl, name);
   }
+
+  String includeTypeNameParam() {
+    return includeTypeNameParam;
+  }
 }
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
index 54b4ca9..394158d 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -17,18 +17,18 @@
 import com.google.gerrit.elasticsearch.builders.BoolQueryBuilder;
 import com.google.gerrit.elasticsearch.builders.QueryBuilder;
 import com.google.gerrit.elasticsearch.builders.QueryBuilders;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.FieldType;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.IntegerRangePredicate;
-import com.google.gerrit.server.index.RegexPredicate;
-import com.google.gerrit.server.index.TimestampRangePredicate;
-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.PostFilterPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.FieldType;
+import com.google.gerrit.index.query.AndPredicate;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.IntegerRangePredicate;
+import com.google.gerrit.index.query.NotPredicate;
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.RegexPredicate;
+import com.google.gerrit.index.query.TimestampRangePredicate;
 import com.google.gerrit.server.query.change.AfterPredicate;
 import java.time.Instant;
 
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index 9c1cf02..1147571 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -129,7 +129,6 @@
 
   private RestClient build() {
     RestClientBuilder builder = RestClient.builder(cfg.getHosts());
-    builder.setMaxRetryTimeoutMillis(cfg.maxRetryTimeout);
     setConfiguredCredentialsIfAny(builder);
     return builder.build();
   }
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
index 6fd234d..14e4623 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
@@ -22,33 +22,33 @@
   private static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
       ImmutableMap.of("\\u002E", "\\u0020", "\\u005F", "\\u0020");
 
-  static SettingProperties createSetting() {
-    ElasticSetting.Builder settings = new ElasticSetting.Builder();
-    settings.addCharFilter();
-    settings.addAnalyzer();
-    return settings.build();
+  static SettingProperties createSetting(ElasticConfiguration config, ElasticQueryAdapter adapter) {
+    return new ElasticSetting.Builder().addCharFilter().addAnalyzer().build(config, adapter);
   }
 
   static class Builder {
     private final ImmutableMap.Builder<String, FieldProperties> fields =
         new ImmutableMap.Builder<>();
 
-    SettingProperties build() {
+    SettingProperties build(ElasticConfiguration config, ElasticQueryAdapter adapter) {
       SettingProperties properties = new SettingProperties();
       properties.analysis = fields.build();
+      properties.numberOfShards = config.getNumberOfShards(adapter);
+      properties.numberOfReplicas = config.numberOfReplicas;
       return properties;
     }
 
-    void addCharFilter() {
+    Builder addCharFilter() {
       FieldProperties charMapping = new FieldProperties("mapping");
       charMapping.mappings = getCustomCharMappings(CUSTOM_CHAR_MAPPING);
 
       FieldProperties charFilter = new FieldProperties();
       charFilter.customMapping = charMapping;
       fields.put("char_filter", charFilter);
+      return this;
     }
 
-    void addAnalyzer() {
+    Builder addAnalyzer() {
       FieldProperties customAnalyzer = new FieldProperties("custom");
       customAnalyzer.tokenizer = "standard";
       customAnalyzer.charFilter = new String[] {"custom_mapping"};
@@ -57,6 +57,7 @@
       FieldProperties analyzer = new FieldProperties();
       analyzer.customWithCharFilter = customAnalyzer;
       fields.put("analyzer", analyzer);
+      return this;
     }
 
     private static String[] getCustomCharMappings(ImmutableMap<String, String> map) {
@@ -72,6 +73,8 @@
 
   static class SettingProperties {
     Map<String, FieldProperties> analysis;
+    Integer numberOfShards;
+    Integer numberOfReplicas;
   }
 
   static class FieldProperties {
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index dfa5d21..baf3e38 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -18,11 +18,18 @@
 import java.util.regex.Pattern;
 
 public enum ElasticVersion {
-  V2_4("2.4.*"),
   V5_6("5.6.*"),
   V6_2("6.2.*"),
   V6_3("6.3.*"),
-  V6_4("6.4.*");
+  V6_4("6.4.*"),
+  V6_5("6.5.*"),
+  V6_6("6.6.*"),
+  V6_7("6.7.*"),
+  V6_8("6.8.*"),
+  V7_0("7.0.*"),
+  V7_1("7.1.*"),
+  V7_2("7.2.*"),
+  V7_3("7.3.*");
 
   private final String version;
   private final Pattern pattern;
@@ -56,7 +63,31 @@
   }
 
   public boolean isV6() {
-    return version.startsWith("6.");
+    return getMajor() == 6;
+  }
+
+  public boolean isV6OrLater() {
+    return isAtLeastVersion(6);
+  }
+
+  public boolean isV7OrLater() {
+    return isAtLeastVersion(7);
+  }
+
+  private boolean isAtLeastVersion(int major) {
+    return getMajor() >= major;
+  }
+
+  public boolean isAtLeastMinorVersion(ElasticVersion version) {
+    return getMajor().equals(version.getMajor()) && getMinor() >= version.getMinor();
+  }
+
+  private Integer getMajor() {
+    return Integer.valueOf(version.split("\\.")[0]);
+  }
+
+  private Integer getMinor() {
+    return Integer.valueOf(version.split("\\.")[1]);
   }
 
   @Override
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
index 84f6857..a693f6d 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
@@ -19,32 +19,25 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.elasticsearch.builders.XContentBuilder;
-import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.index.Schema.Values;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.Schema.Values;
 import java.io.IOException;
 
 public class UpdateRequest<V> extends BulkRequest {
 
-  private final FillArgs fillArgs;
   private final Schema<V> schema;
   private final V v;
 
-  public UpdateRequest(FillArgs fillArgs, Schema<V> schema, V v) {
-    this.fillArgs = fillArgs;
+  public UpdateRequest(Schema<V> schema, V v) {
     this.schema = schema;
     this.v = v;
   }
 
-  public UpdateRequest(Schema<V> schema, V v) {
-    this(null, schema, v);
-  }
-
   @Override
   protected String getRequest() {
     try (XContentBuilder closeable = new XContentBuilder()) {
       XContentBuilder builder = closeable.startObject();
-      for (Values<V> values : schema.buildFields(v, fillArgs)) {
+      for (Values<V> values : schema.buildFields(v)) {
         String name = values.getField().getName();
         if (values.getField().isRepeatable()) {
           builder.field(
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticContainer.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticContainer.java
deleted file mode 100644
index 9bdf4eb..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticContainer.java
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.testing;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticVersion;
-import java.util.Set;
-import org.apache.http.HttpHost;
-import org.junit.internal.AssumptionViolatedException;
-import org.testcontainers.containers.GenericContainer;
-
-/* Helper class for running ES integration tests in docker container */
-public class ElasticContainer<SELF extends ElasticContainer<SELF>> extends GenericContainer<SELF> {
-  private static final int ELASTICSEARCH_DEFAULT_PORT = 9200;
-
-  public static ElasticContainer<?> createAndStart(ElasticVersion version) {
-    // Assumption violation is not natively supported by Testcontainers.
-    // See https://github.com/testcontainers/testcontainers-java/issues/343
-    try {
-      ElasticContainer<?> container = new ElasticContainer<>(version);
-      container.start();
-      return container;
-    } catch (Throwable t) {
-      throw new AssumptionViolatedException("Unable to start container", t);
-    }
-  }
-
-  public static ElasticContainer<?> createAndStart() {
-    return createAndStart(ElasticVersion.V2_4);
-  }
-
-  private static String getImageName(ElasticVersion version) {
-    switch (version) {
-      case V2_4:
-        return "elasticsearch:2.4.6-alpine";
-      case V5_6:
-        return "docker.elastic.co/elasticsearch/elasticsearch:5.6.15";
-      case V6_2:
-        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.4";
-      case V6_3:
-        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2";
-      case V6_4:
-        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.4.3";
-    }
-    throw new IllegalStateException("No tests for version: " + version.name());
-  }
-
-  private ElasticContainer(ElasticVersion version) {
-    super(getImageName(version));
-  }
-
-  @Override
-  protected void configure() {
-    addExposedPort(ELASTICSEARCH_DEFAULT_PORT);
-
-    // https://github.com/docker-library/elasticsearch/issues/58
-    addEnv("-Ees.network.host", "0.0.0.0");
-  }
-
-  @Override
-  public Set<Integer> getLivenessCheckPortNumbers() {
-    return ImmutableSet.of(getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
-  }
-
-  public HttpHost getHttpHost() {
-    return new HttpHost(getContainerIpAddress(), getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
-  }
-}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticTestUtils.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticTestUtils.java
deleted file mode 100644
index d2e0bc6..0000000
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/testing/ElasticTestUtils.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.testing;
-
-import com.google.gerrit.elasticsearch.ElasticVersion;
-import com.google.gerrit.server.index.IndexDefinition;
-import com.google.gerrit.server.index.IndexModule.IndexType;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.TypeLiteral;
-import java.io.IOException;
-import java.util.Collection;
-import org.eclipse.jgit.lib.Config;
-
-public final class ElasticTestUtils {
-  public static class ElasticNodeInfo {
-    public final int port;
-
-    public ElasticNodeInfo(int port) {
-      this.port = port;
-    }
-  }
-
-  public static void configure(Config config, int port, String prefix, ElasticVersion version) {
-    config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
-    config.setString("elasticsearch", null, "server", "http://localhost:" + port);
-    config.setString("elasticsearch", null, "prefix", prefix);
-    config.setInt("index", null, "maxLimit", 10000);
-    String password = version == ElasticVersion.V5_6 ? "changeme" : null;
-    if (password != null) {
-      config.setString("elasticsearch", null, "password", password);
-    }
-  }
-
-  public static void configure(Config config, int port, String prefix) {
-    configure(config, port, prefix, null);
-  }
-
-  public static void createAllIndexes(Injector injector) throws IOException {
-    Collection<IndexDefinition<?, ?, ?>> indexDefs =
-        injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {}));
-    for (IndexDefinition<?, ?, ?> indexDef : indexDefs) {
-      indexDef.getIndexCollection().getSearchIndex().deleteAll();
-    }
-  }
-
-  private ElasticTestUtils() {
-    // hide default constructor
-  }
-}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
index 559b8c7..ff7b5ca 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
@@ -15,21 +15,17 @@
 package com.google.gerrit.elasticsearch;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.DEFAULT_MAX_RETRY_TIMEOUT_MS;
 import static com.google.gerrit.elasticsearch.ElasticConfiguration.DEFAULT_USERNAME;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_MAX_RETRY_TIMEOUT;
 import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PASSWORD;
 import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PREFIX;
 import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_SERVER;
 import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_USERNAME;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.MAX_RETRY_TIMEOUT_UNIT;
 import static com.google.gerrit.elasticsearch.ElasticConfiguration.SECTION_ELASTICSEARCH;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.inject.ProvisionException;
 import java.util.Arrays;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Rule;
 import org.junit.Test;
@@ -46,7 +42,6 @@
     assertThat(esCfg.username).isNull();
     assertThat(esCfg.password).isNull();
     assertThat(esCfg.prefix).isEmpty();
-    assertThat(esCfg.maxRetryTimeout).isEqualTo(DEFAULT_MAX_RETRY_TIMEOUT_MS);
   }
 
   @Test
@@ -66,23 +61,6 @@
   }
 
   @Test
-  public void maxRetryTimeoutInDefaultUnit() {
-    Config cfg = newConfig();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_MAX_RETRY_TIMEOUT, "45000");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertThat(esCfg.maxRetryTimeout).isEqualTo(45000);
-  }
-
-  @Test
-  public void maxRetryTimeoutInOtherUnit() {
-    Config cfg = newConfig();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_MAX_RETRY_TIMEOUT, "45 s");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertThat(esCfg.maxRetryTimeout)
-        .isEqualTo(MAX_RETRY_TIMEOUT_UNIT.convert(45, TimeUnit.SECONDS));
-  }
-
-  @Test
   public void withAuthentication() throws Exception {
     Config cfg = newConfig();
     cfg.setString(SECTION_ELASTICSEARCH, null, KEY_USERNAME, "myself");
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
new file mode 100644
index 0000000..265c61c
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import org.apache.http.HttpHost;
+import org.junit.internal.AssumptionViolatedException;
+import org.testcontainers.elasticsearch.ElasticsearchContainer;
+
+/* Helper class for running ES integration tests in docker container */
+public class ElasticContainer extends ElasticsearchContainer {
+  private static final int ELASTICSEARCH_DEFAULT_PORT = 9200;
+
+  public static ElasticContainer createAndStart(ElasticVersion version) {
+    // Assumption violation is not natively supported by Testcontainers.
+    // See https://github.com/testcontainers/testcontainers-java/issues/343
+    try {
+      ElasticContainer container = new ElasticContainer(version);
+      container.start();
+      return container;
+    } catch (Throwable t) {
+      throw new AssumptionViolatedException("Unable to start container", t);
+    }
+  }
+
+  private static String getImageName(ElasticVersion version) {
+    switch (version) {
+      case V5_6:
+        return "blacktop/elasticsearch:5.6.16";
+      case V6_2:
+        return "blacktop/elasticsearch:6.2.4";
+      case V6_3:
+        return "blacktop/elasticsearch:6.3.2";
+      case V6_4:
+        return "blacktop/elasticsearch:6.4.3";
+      case V6_5:
+        return "blacktop/elasticsearch:6.5.4";
+      case V6_6:
+        return "blacktop/elasticsearch:6.6.2";
+      case V6_7:
+        return "blacktop/elasticsearch:6.7.2";
+      case V6_8:
+        return "blacktop/elasticsearch:6.8.2";
+      case V7_0:
+        return "blacktop/elasticsearch:7.0.1";
+      case V7_1:
+        return "blacktop/elasticsearch:7.1.1";
+      case V7_2:
+        return "blacktop/elasticsearch:7.2.1";
+      case V7_3:
+        return "blacktop/elasticsearch:7.3.2";
+    }
+    throw new IllegalStateException("No tests for version: " + version.name());
+  }
+
+  private ElasticContainer(ElasticVersion version) {
+    super(getImageName(version));
+  }
+
+  public HttpHost getHttpHost() {
+    return new HttpHost(getContainerIpAddress(), getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
deleted file mode 100644
index 9b0b71d..0000000
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryAccountsTest.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticQueryAccountsTest extends AbstractQueryAccountsTest {
-  private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
-    }
-
-    container = ElasticContainer.createAndStart();
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
-  }
-}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
deleted file mode 100644
index 4bfa075..0000000
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
+++ /dev/null
@@ -1,68 +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.elasticsearch;
-
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticQueryChangesTest extends AbstractQueryChangesTest {
-  private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
-    }
-
-    container = ElasticContainer.createAndStart();
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
-  }
-}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
deleted file mode 100644
index 4236a5b..0000000
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryGroupsTest.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// 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.elasticsearch;
-
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
-import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticQueryGroupsTest extends AbstractQueryGroupsTest {
-  private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (nodeInfo != null) {
-      // do not start Elasticsearch twice
-      return;
-    }
-
-    container = ElasticContainer.createAndStart();
-    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
-    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
-  }
-}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
new file mode 100644
index 0000000..020a158
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.UUID;
+import org.eclipse.jgit.lib.Config;
+
+public final class ElasticTestUtils {
+  public static class ElasticNodeInfo {
+    public final int port;
+
+    public ElasticNodeInfo(int port) {
+      this.port = port;
+    }
+  }
+
+  public static void configure(Config config, int port, String prefix, ElasticVersion version) {
+    config.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
+    config.setString("elasticsearch", null, "server", "http://localhost:" + port);
+    config.setString("elasticsearch", null, "prefix", prefix);
+    config.setInt("index", null, "maxLimit", 10000);
+    String password = version == ElasticVersion.V5_6 ? "changeme" : null;
+    if (password != null) {
+      config.setString("elasticsearch", null, "password", password);
+    }
+  }
+
+  public static void configure(Config config, int port, String prefix) {
+    configure(config, port, prefix, null);
+  }
+
+  public static void createAllIndexes(Injector injector) throws IOException {
+    Collection<IndexDefinition<?, ?, ?>> indexDefs =
+        injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {}));
+    for (IndexDefinition<?, ?, ?> indexDef : indexDefs) {
+      indexDef.getIndexCollection().getSearchIndex().deleteAll();
+    }
+  }
+
+  public static Config getConfig(ElasticVersion version) {
+    ElasticNodeInfo elasticNodeInfo;
+    ElasticContainer container = ElasticContainer.createAndStart(version);
+    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+    String indicesPrefix = UUID.randomUUID().toString();
+    Config cfg = new Config();
+    configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
+    return cfg;
+  }
+
+  private ElasticTestUtils() {
+    // hide default constructor
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
index 60657be..01a523b 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryAccountsTest.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.inject.Guice;
@@ -27,7 +25,7 @@
 
 public class ElasticV5QueryAccountsTest extends AbstractQueryAccountsTest {
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -47,10 +45,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -61,7 +55,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
index 076fad9..d60b7ec 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryChangesTest.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.inject.Guice;
@@ -27,7 +25,7 @@
 
 public class ElasticV5QueryChangesTest extends AbstractQueryChangesTest {
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -47,10 +45,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -61,7 +55,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
index d16a52a..e8d9a78 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV5QueryGroupsTest.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.inject.Guice;
@@ -27,7 +25,7 @@
 
 public class ElasticV5QueryGroupsTest extends AbstractQueryGroupsTest {
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -47,10 +45,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -61,7 +55,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(
         elasticsearchConfig, nodeInfo.port, indicesPrefix, ElasticVersion.V5_6);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index b1e70b4..178aba5 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.inject.Guice;
@@ -27,7 +25,7 @@
 
 public class ElasticV6QueryAccountsTest extends AbstractQueryAccountsTest {
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -36,7 +34,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
@@ -47,10 +45,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -61,7 +55,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index f2b4eff..8a1cde3 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.inject.Guice;
@@ -28,7 +26,7 @@
 public class ElasticV6QueryChangesTest extends AbstractQueryChangesTest {
 
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -37,7 +35,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
@@ -48,10 +46,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -62,7 +56,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index 1cfca5e..b87a920 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.gerrit.elasticsearch.testing.ElasticContainer;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.testing.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
 import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.inject.Guice;
@@ -27,7 +25,7 @@
 
 public class ElasticV6QueryGroupsTest extends AbstractQueryGroupsTest {
   private static ElasticNodeInfo nodeInfo;
-  private static ElasticContainer<?> container;
+  private static ElasticContainer container;
 
   @BeforeClass
   public static void startIndexService() {
@@ -36,7 +34,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_8);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
@@ -47,10 +45,6 @@
     }
   }
 
-  private String testName() {
-    return testName.getMethodName().toLowerCase() + "_";
-  }
-
   @Override
   protected void initAfterLifecycleStart() throws Exception {
     super.initAfterLifecycleStart();
@@ -61,7 +55,7 @@
   protected Injector createInjector() {
     Config elasticsearchConfig = new Config(config);
     InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName();
+    String indicesPrefix = getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
     return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
   }
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
new file mode 100644
index 0000000..4a52107
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV7QueryAccountsTest extends AbstractQueryAccountsTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V7_3);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = getSanitizedMethodName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
new file mode 100644
index 0000000..2301082
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.apache.http.impl.nio.client.HttpAsyncClients;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV7QueryChangesTest extends AbstractQueryChangesTest {
+
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer container;
+  private static CloseableHttpAsyncClient client;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V7_3);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+    client = HttpAsyncClients.createDefault();
+    client.start();
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @After
+  public void closeIndex() {
+    client.execute(
+        new HttpPost(
+            String.format(
+                "http://localhost:%d/%s*/_close", nodeInfo.port, getSanitizedMethodName())),
+        HttpClientContext.create(),
+        null);
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = getSanitizedMethodName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
new file mode 100644
index 0000000..82f9abe
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticV7QueryGroupsTest extends AbstractQueryGroupsTest {
+  private static ElasticNodeInfo nodeInfo;
+  private static ElasticContainer container;
+
+  @BeforeClass
+  public static void startIndexService() {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+
+    container = ElasticContainer.createAndStart(ElasticVersion.V7_3);
+    nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    String indicesPrefix = getSanitizedMethodName();
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port, indicesPrefix);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index b598a0a..707405b 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -25,9 +25,6 @@
 
   @Test
   public void supportedVersion() throws Exception {
-    assertThat(ElasticVersion.forVersion("2.4.0")).isEqualTo(ElasticVersion.V2_4);
-    assertThat(ElasticVersion.forVersion("2.4.6")).isEqualTo(ElasticVersion.V2_4);
-
     assertThat(ElasticVersion.forVersion("5.6.0")).isEqualTo(ElasticVersion.V5_6);
     assertThat(ElasticVersion.forVersion("5.6.11")).isEqualTo(ElasticVersion.V5_6);
 
@@ -39,6 +36,30 @@
 
     assertThat(ElasticVersion.forVersion("6.4.0")).isEqualTo(ElasticVersion.V6_4);
     assertThat(ElasticVersion.forVersion("6.4.1")).isEqualTo(ElasticVersion.V6_4);
+
+    assertThat(ElasticVersion.forVersion("6.5.0")).isEqualTo(ElasticVersion.V6_5);
+    assertThat(ElasticVersion.forVersion("6.5.1")).isEqualTo(ElasticVersion.V6_5);
+
+    assertThat(ElasticVersion.forVersion("6.6.0")).isEqualTo(ElasticVersion.V6_6);
+    assertThat(ElasticVersion.forVersion("6.6.1")).isEqualTo(ElasticVersion.V6_6);
+
+    assertThat(ElasticVersion.forVersion("6.7.0")).isEqualTo(ElasticVersion.V6_7);
+    assertThat(ElasticVersion.forVersion("6.7.1")).isEqualTo(ElasticVersion.V6_7);
+
+    assertThat(ElasticVersion.forVersion("6.8.0")).isEqualTo(ElasticVersion.V6_8);
+    assertThat(ElasticVersion.forVersion("6.8.1")).isEqualTo(ElasticVersion.V6_8);
+
+    assertThat(ElasticVersion.forVersion("7.0.0")).isEqualTo(ElasticVersion.V7_0);
+    assertThat(ElasticVersion.forVersion("7.0.1")).isEqualTo(ElasticVersion.V7_0);
+
+    assertThat(ElasticVersion.forVersion("7.1.0")).isEqualTo(ElasticVersion.V7_1);
+    assertThat(ElasticVersion.forVersion("7.1.1")).isEqualTo(ElasticVersion.V7_1);
+
+    assertThat(ElasticVersion.forVersion("7.2.0")).isEqualTo(ElasticVersion.V7_2);
+    assertThat(ElasticVersion.forVersion("7.2.1")).isEqualTo(ElasticVersion.V7_2);
+
+    assertThat(ElasticVersion.forVersion("7.3.0")).isEqualTo(ElasticVersion.V7_3);
+    assertThat(ElasticVersion.forVersion("7.3.1")).isEqualTo(ElasticVersion.V7_3);
   }
 
   @Test
@@ -50,10 +71,50 @@
   }
 
   @Test
-  public void version6() throws Exception {
-    assertThat(ElasticVersion.V6_2.isV6()).isTrue();
-    assertThat(ElasticVersion.V6_3.isV6()).isTrue();
-    assertThat(ElasticVersion.V6_4.isV6()).isTrue();
-    assertThat(ElasticVersion.V5_6.isV6()).isFalse();
+  public void atLeastMinorVersion() throws Exception {
+    assertThat(ElasticVersion.V5_6.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_2.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_3.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_4.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_5.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_6.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V6_7.isAtLeastMinorVersion(ElasticVersion.V6_7)).isTrue();
+    assertThat(ElasticVersion.V6_8.isAtLeastMinorVersion(ElasticVersion.V6_8)).isTrue();
+    assertThat(ElasticVersion.V7_0.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V7_1.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V7_2.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V7_3.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+  }
+
+  @Test
+  public void version6OrLater() throws Exception {
+    assertThat(ElasticVersion.V5_6.isV6OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_2.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_3.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_4.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_5.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_6.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_7.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V6_8.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_0.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_1.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_2.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_3.isV6OrLater()).isTrue();
+  }
+
+  @Test
+  public void version7OrLater() throws Exception {
+    assertThat(ElasticVersion.V5_6.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_2.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_3.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_4.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_5.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_6.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_7.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V6_8.isV7OrLater()).isFalse();
+    assertThat(ElasticVersion.V7_0.isV7OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_1.isV7OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_2.isV7OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_3.isV7OrLater()).isTrue();
   }
 }
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index 4d156c6..259fe15 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.14.20</version>
+  <version>2.15.17-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
index 05fd5b2..1295ea0 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
@@ -21,5 +21,10 @@
     return new ExportImpl(name);
   }
 
+  /** Create an annotation to export based on a cannonical class name. */
+  public static Export named(Class<?> clazz) {
+    return named(clazz.getCanonicalName());
+  }
+
   private Exports() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java
index f97abd9..1e3a2c8 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresAnyCapability.java
@@ -33,4 +33,7 @@
 
   /** Scope of the named capabilities. */
   CapabilityScope scope() default CapabilityScope.CONTEXT;
+
+  /** Fall back to admin credentials. Only applies to plugin capability check. */
+  boolean fallBackToAdmin() default true;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java
index 7717c84..b9ef7e0 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/RequiresCapability.java
@@ -32,4 +32,7 @@
 
   /** Scope of the named capability. */
   CapabilityScope scope() default CapabilityScope.CONTEXT;
+
+  /** Fall back to admin credentials. Only applies to plugin capability check. */
+  boolean fallBackToAdmin() default true;
 }
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 8dcc49d..eebb555 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
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.config.Config;
 import com.google.gerrit.extensions.api.groups.Groups;
+import com.google.gerrit.extensions.api.plugins.Plugins;
 import com.google.gerrit.extensions.api.projects.Projects;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 
@@ -32,6 +33,8 @@
 
   Projects projects();
 
+  Plugins plugins();
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -61,5 +64,10 @@
     public Projects projects() {
       throw new NotImplementedException();
     }
+
+    @Override
+    public Plugins plugins() {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
new file mode 100644
index 0000000..deae084
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
@@ -0,0 +1,26 @@
+// 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.api.access;
+
+/**
+ * A {@link com.google.gerrit.server.permissions.GlobalPermission} or a {@link PluginPermission}.
+ */
+public interface GlobalOrPluginPermission {
+  /** @return name used in {@code project.config} permissions. */
+  public String permissionName();
+
+  /** @return readable identifier of this permission for exception message. */
+  public String describeForException();
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java
new file mode 100644
index 0000000..7a467b8
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java
@@ -0,0 +1,77 @@
+// 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.api.access;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.util.Objects;
+
+/** A global capability type permission used by a plugin. */
+public class PluginPermission implements GlobalOrPluginPermission {
+  private final String pluginName;
+  private final String capability;
+  private final boolean fallBackToAdmin;
+
+  public PluginPermission(String pluginName, String capability) {
+    this(pluginName, capability, true);
+  }
+
+  public PluginPermission(String pluginName, String capability, boolean fallBackToAdmin) {
+    this.pluginName = checkNotNull(pluginName, "pluginName");
+    this.capability = checkNotNull(capability, "capability");
+    this.fallBackToAdmin = fallBackToAdmin;
+  }
+
+  public String pluginName() {
+    return pluginName;
+  }
+
+  public String capability() {
+    return capability;
+  }
+
+  public boolean fallBackToAdmin() {
+    return fallBackToAdmin;
+  }
+
+  @Override
+  public String permissionName() {
+    return pluginName + '-' + capability;
+  }
+
+  @Override
+  public String describeForException() {
+    return capability + " for plugin " + pluginName;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(pluginName, capability);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other instanceof PluginPermission) {
+      PluginPermission b = (PluginPermission) other;
+      return pluginName.equals(b.pluginName) && capability.equals(b.capability);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return "PluginPermission[plugin=" + pluginName + ", capability=" + capability + ']';
+  }
+}
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
index 995c664..bc5daf6 100644
--- 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
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.access;
 
+import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import java.util.Map;
 import java.util.Set;
@@ -28,4 +29,5 @@
   public Boolean canAdd;
   public Boolean canAddTags;
   public Boolean configVisible;
+  public Map<String, GroupInfo> groups;
 }
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 b88097c..3b2963a 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
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -69,6 +70,8 @@
 
   List<ChangeInfo> getStarredChanges() throws RestApiException;
 
+  List<GroupInfo> getGroups() throws RestApiException;
+
   List<EmailInfo> getEmails() throws RestApiException;
 
   void addEmail(EmailInput input) throws RestApiException;
@@ -99,6 +102,25 @@
 
   void deleteExternalIds(List<String> externalIds) throws RestApiException;
 
+  void setName(String name) throws RestApiException;
+
+  /**
+   * Generate a new HTTP password.
+   *
+   * @return the generated password.
+   */
+  String generateHttpPassword() throws RestApiException;
+
+  /**
+   * Set a new HTTP password.
+   *
+   * <p>May only be invoked by administrators.
+   *
+   * @param httpPassword the new password, {@code null} to remove the password.
+   * @return the new password, {@code null} if the password was removed.
+   */
+  String setHttpPassword(String httpPassword) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -197,6 +219,11 @@
     }
 
     @Override
+    public List<GroupInfo> getGroups() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public List<EmailInfo> getEmails() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -271,5 +298,20 @@
     public void deleteExternalIds(List<String> externalIds) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void setName(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String generateHttpPassword() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String setHttpPassword(String httpPassword) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
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 e92d229..8ab5110 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
@@ -166,16 +166,19 @@
       return this;
     }
 
+    /** Set an option on the request, appending to existing options. */
     public QueryRequest withOption(ListAccountsOption options) {
       this.options.add(options);
       return this;
     }
 
+    /** Set options on the request, appending to existing options. */
     public QueryRequest withOptions(ListAccountsOption... options) {
       this.options.addAll(Arrays.asList(options));
       return this;
     }
 
+    /** Set options on the request, replacing existing options. */
     public QueryRequest withOptions(EnumSet<ListAccountsOption> options) {
       this.options = options;
       return this;
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 b3ba1e2..1d82178 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,6 +19,6 @@
 
 public class AbandonInput {
   @DefaultInput public String message;
-  public NotifyHandling notify = NotifyHandling.ALL;
+  public NotifyHandling notify;
   public Map<RecipientType, NotifyInfo> notifyDetails;
 }
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 8c1ebf3..202a67d 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
@@ -14,16 +14,21 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 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;
 import java.util.Map;
@@ -85,6 +90,46 @@
 
   void move(MoveInput in) throws RestApiException;
 
+  void setPrivate(boolean value, @Nullable String message) throws RestApiException;
+
+  default void setPrivate(boolean value) throws RestApiException {
+    setPrivate(value, null);
+  }
+
+  void setWorkInProgress(@Nullable String message) throws RestApiException;
+
+  void setReadyForReview(@Nullable String message) throws RestApiException;
+
+  default void setWorkInProgress() throws RestApiException {
+    setWorkInProgress(null);
+  }
+
+  default void setReadyForReview() throws RestApiException {
+    setReadyForReview(null);
+  }
+
+  /**
+   * Ignore or un-ignore this change.
+   *
+   * @param ignore ignore the change if true
+   */
+  void ignore(boolean ignore) throws RestApiException;
+
+  /**
+   * Check if this change is ignored.
+   *
+   * @return true if the change is ignored
+   */
+  boolean ignored() throws RestApiException;
+
+  /**
+   * Mark this change as reviewed/unreviewed.
+   *
+   * @param reviewed flag to decide if this change should be marked as reviewed ({@code true}) or
+   *     unreviewed ({@code false})
+   */
+  void markAsReviewed(boolean reviewed) throws RestApiException;
+
   /**
    * Create a new change that reverts this change.
    *
@@ -112,6 +157,7 @@
       throws RestApiException;
 
   /** Publishes a draft change. */
+  @Deprecated
   void publish() throws RestApiException;
 
   /** Rebase the current revision of a change using default options. */
@@ -129,16 +175,29 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
-  void addReviewer(AddReviewerInput in) throws RestApiException;
+  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
 
-  void addReviewer(String in) throws RestApiException;
+  AddReviewerResult addReviewer(String in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException;
 
+  /**
+   * Retrieve reviewers ({@code ReviewerState.REVIEWER} and {@code ReviewerState.CC}) on the change.
+   */
+  List<ReviewerInfo> reviewers() throws RestApiException;
+
   ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException;
 
+  default ChangeInfo get(Iterable<ListChangesOption> options) throws RestApiException {
+    return get(Sets.newEnumSet(options, ListChangesOption.class));
+  }
+
+  default ChangeInfo get(ListChangesOption... options) throws RestApiException {
+    return get(Arrays.asList(options));
+  }
+
   /** {@code get} with {@link ListChangesOption} set to all except CHECK. */
   ChangeInfo get() throws RestApiException;
   /** {@code get} with {@link ListChangesOption} set to none. */
@@ -161,6 +220,12 @@
    */
   ChangeEditApi edit() throws RestApiException;
 
+  /** Create a new patch set with a new commit message. */
+  void setMessage(String message) throws RestApiException;
+
+  /** Create a new patch set with a new commit message. */
+  void setMessage(CommitMessageInput in) throws RestApiException;
+
   /** Set hashtags on a change */
   void setHashtags(HashtagsInput input) throws RestApiException;
 
@@ -198,6 +263,15 @@
   Map<String, List<CommentInfo>> comments() throws RestApiException;
 
   /**
+   * Get all published comments on a change as a list.
+   *
+   * @return comments as a list; comments have the {@code revision} field set to indicate their
+   *     patch set.
+   * @throws RestApiException
+   */
+  List<CommentInfo> commentsAsList() throws RestApiException;
+
+  /**
    * Get all robot comments on a change.
    *
    * @return robot comments in a map keyed by path; robot comments have the {@code revision} field
@@ -215,12 +289,27 @@
    */
   Map<String, List<CommentInfo>> drafts() throws RestApiException;
 
+  /**
+   * Get all draft comments for the current user on a change as a list.
+   *
+   * @return drafts as a list; comments have the {@code revision} field set to indicate their patch
+   *     set.
+   * @throws RestApiException
+   */
+  List<CommentInfo> draftsAsList() throws RestApiException;
+
   ChangeInfo check() throws RestApiException;
 
   ChangeInfo check(FixInput fix) throws RestApiException;
 
   void index() throws RestApiException;
 
+  /** Check if this change is a pure revert of the change stored in revertOf. */
+  PureRevertInfo pureRevert() throws RestApiException;
+
+  /** Check if this change is a pure revert of claimedOriginal (SHA1 in 40 digit hex). */
+  PureRevertInfo pureRevert(String claimedOriginal) throws RestApiException;
+
   abstract class SuggestedReviewersRequest {
     private String query;
     private int limit;
@@ -307,6 +396,21 @@
     }
 
     @Override
+    public void setPrivate(boolean value, @Nullable String message) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setWorkInProgress(String message) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setReadyForReview(String message) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeApi revert() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -321,6 +425,7 @@
       throw new NotImplementedException();
     }
 
+    @Deprecated
     @Override
     public void rebase() throws RestApiException {
       throw new NotImplementedException();
@@ -352,12 +457,12 @@
     }
 
     @Override
-    public void addReviewer(AddReviewerInput in) throws RestApiException {
+    public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void addReviewer(String in) throws RestApiException {
+    public AddReviewerResult addReviewer(String in) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -372,6 +477,11 @@
     }
 
     @Override
+    public List<ReviewerInfo> reviewers() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -387,6 +497,16 @@
     }
 
     @Override
+    public void setMessage(String message) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setMessage(CommitMessageInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public EditInfo getEdit() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -432,6 +552,11 @@
     }
 
     @Override
+    public List<CommentInfo> commentsAsList() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -442,6 +567,11 @@
     }
 
     @Override
+    public List<CommentInfo> draftsAsList() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeInfo check() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -477,5 +607,30 @@
     public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void ignore(boolean ignore) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public boolean ignored() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void markAsReviewed(boolean reviewed) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public PureRevertInfo pureRevert() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public PureRevertInfo pureRevert(String claimedOriginal) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
index 9d0275a..25eb7a8 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.EnumSet;
 import java.util.Optional;
 
 /**
@@ -29,6 +31,33 @@
  */
 public interface ChangeEditApi {
 
+  abstract class ChangeEditDetailRequest {
+    private String base;
+    private EnumSet<ChangeEditDetailOption> options = EnumSet.noneOf(ChangeEditDetailOption.class);
+
+    public abstract Optional<EditInfo> get() throws RestApiException;
+
+    public ChangeEditDetailRequest withBase(String base) {
+      this.base = base;
+      return this;
+    }
+
+    public ChangeEditDetailRequest withOption(ChangeEditDetailOption option) {
+      this.options.add(option);
+      return this;
+    }
+
+    public String getBase() {
+      return base;
+    }
+
+    public EnumSet<ChangeEditDetailOption> options() {
+      return options;
+    }
+  }
+
+  ChangeEditDetailRequest detail() throws RestApiException;
+
   /**
    * Retrieves details regarding the change edit.
    *
@@ -156,6 +185,11 @@
    */
   class NotImplemented implements ChangeEditApi {
     @Override
+    public ChangeEditDetailRequest detail() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Optional<EditInfo> get() 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 d14ddfe..dc0a250 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
@@ -56,6 +56,15 @@
    */
   ChangeApi id(String project, String branch, String id) throws RestApiException;
 
+  /**
+   * Look up a change by project and numeric ID.
+   *
+   * @param project project name.
+   * @param id change number.
+   * @see #id(int)
+   */
+  ChangeApi id(String project, int id) throws RestApiException;
+
   ChangeApi create(ChangeInput in) throws RestApiException;
 
   QueryRequest query();
@@ -85,16 +94,19 @@
       return this;
     }
 
+    /** Set an option on the request, appending to existing options. */
     public QueryRequest withOption(ListChangesOption options) {
       this.options.add(options);
       return this;
     }
 
+    /** Set options on the request, appending to existing options. */
     public QueryRequest withOptions(ListChangesOption... options) {
       this.options.addAll(Arrays.asList(options));
       return this;
     }
 
+    /** Set options on the request, replacing existing options. */
     public QueryRequest withOptions(EnumSet<ListChangesOption> options) {
       this.options = options;
       return this;
@@ -153,6 +165,11 @@
     }
 
     @Override
+    public ChangeApi id(String project, int id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     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/CherryPickInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 2e1bb13..694e06b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -14,8 +14,18 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import java.util.Map;
+
 public class CherryPickInput {
   public String message;
+  // Cherry-pick destination branch, which will be the destination of the newly created change.
   public String destination;
+  // 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created change.
+  public String base;
   public Integer parent;
+
+  public NotifyHandling notify = NotifyHandling.NONE;
+  public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  public boolean keepReviewers;
 }
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 a6d64a6..889175e 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
@@ -22,6 +22,17 @@
   CommentInfo get() throws RestApiException;
 
   /**
+   * Deletes a published comment of a revision. For NoteDb, it deletes the comment by rewriting the
+   * commit history.
+   *
+   * <p>Note instead of deleting the whole comment, this endpoint just replaces the comment's
+   * message.
+   *
+   * @return the comment with its message updated.
+   */
+  CommentInfo delete(DeleteCommentInput input) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -30,5 +41,10 @@
     public CommentInfo get() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public CommentInfo delete(DeleteCommentInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java
new file mode 100644
index 0000000..75fd16b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java
@@ -0,0 +1,30 @@
+// 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.api.changes;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class DeleteCommentInput {
+  @DefaultInput public String reason;
+
+  public DeleteCommentInput() {
+    reason = "";
+  }
+
+  public DeleteCommentInput(String reason) {
+    this.reason = Strings.nullToEmpty(reason);
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
index 34f550b..5be5f33 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteReviewerInput.java
@@ -19,7 +19,7 @@
 /** Input passed to {@code DELETE /changes/[id]/reviewers/[id]}. */
 public class DeleteReviewerInput {
   /** Who to send email notifications to after the reviewer is deleted. */
-  public NotifyHandling notify = NotifyHandling.ALL;
+  public NotifyHandling notify = null;
 
   public Map<RecipientType, NotifyInfo> notifyDetails;
 }
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 e2bd074..39cf2b7 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
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.OptionalInt;
 
 public interface FileApi {
   BinaryResult content() throws RestApiException;
@@ -38,11 +39,15 @@
    */
   DiffRequest diffRequest() throws RestApiException;
 
+  /** Set the file reviewed or not reviewed */
+  void setReviewed(boolean reviewed) throws RestApiException;
+
   abstract class DiffRequest {
     private String base;
     private Integer context;
     private Boolean intraline;
     private Whitespace whitespace;
+    private OptionalInt parent = OptionalInt.empty();
 
     public abstract DiffInfo get() throws RestApiException;
 
@@ -66,6 +71,11 @@
       return this;
     }
 
+    public DiffRequest withParent(int parent) {
+      this.parent = OptionalInt.of(parent);
+      return this;
+    }
+
     public String getBase() {
       return base;
     }
@@ -81,6 +91,10 @@
     public Whitespace getWhitespace() {
       return whitespace;
     }
+
+    public OptionalInt getParent() {
+      return parent;
+    }
   }
 
   /**
@@ -112,5 +126,10 @@
     public DiffRequest diffRequest() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void setReviewed(boolean reviewed) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
index 8f66f12..bbc8a2e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/HashtagsInput.java
@@ -26,4 +26,9 @@
   public HashtagsInput(Set<String> add) {
     this.add = add;
   }
+
+  public HashtagsInput(Set<String> add, Set<String> remove) {
+    this(add);
+    this.remove = remove;
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
new file mode 100644
index 0000000..5bf22aa
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.base.MoreObjects;
+import com.google.gerrit.extensions.common.CommitInfo;
+
+public class RelatedChangeAndCommitInfo {
+  public String project;
+  public String changeId;
+  public CommitInfo commit;
+  public Integer _changeNumber;
+  public Integer _revisionNumber;
+  public Integer _currentRevisionNumber;
+  public String status;
+
+  public RelatedChangeAndCommitInfo() {}
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("project", project)
+        .add("changeId", changeId)
+        .add("commit", commit)
+        .add("_changeNumber", _changeNumber)
+        .add("_revisionNumber", _revisionNumber)
+        .add("_currentRevisionNumber", _currentRevisionNumber)
+        .add("status", status)
+        .toString();
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RelatedChangesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RelatedChangesInfo.java
new file mode 100644
index 0000000..e1e70f3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RelatedChangesInfo.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.List;
+
+public class RelatedChangesInfo {
+  public List<RelatedChangeAndCommitInfo> changes;
+}
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 0eb076e..69acf75 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
@@ -36,15 +36,6 @@
   public Map<String, List<RobotCommentInput>> robotComments;
 
   /**
-   * If true require all labels to be within the user's permitted ranges based on access controls,
-   * attempting to use a label not granted to the user will fail the entire modify operation early.
-   * If false the operation will execute anyway, but the proposed labels given by the user will be
-   * modified to be the "best" value allowed by the access controls, or ignored if the label does
-   * not exist.
-   */
-  public boolean strictLabels = true;
-
-  /**
    * How to process draft comments already in the database that were not also described in this
    * input request.
    *
@@ -54,7 +45,7 @@
   public DraftHandling drafts;
 
   /** Who to send email notifications to after review is stored. */
-  public NotifyHandling notify = NotifyHandling.ALL;
+  public NotifyHandling notify;
 
   public Map<RecipientType, NotifyInfo> notifyDetails;
 
@@ -66,14 +57,24 @@
    * on behalf of this named user instead of the caller. Caller must have the labelAs-$NAME
    * permission granted for each label that appears in {@link #labels}. This is in addition to the
    * named user also needing to have permission to use the labels.
-   *
-   * <p>{@link #strictLabels} impacts how labels is processed for the named user, not the caller.
    */
   public String onBehalfOf;
 
   /** Reviewers that should be added to this change. */
   public List<AddReviewerInput> reviewers;
 
+  /**
+   * If true mark the change as work in progress. It is an error for both {@link #workInProgress}
+   * and {@link #ready} to be true.
+   */
+  public boolean workInProgress;
+
+  /**
+   * If true mark the change as ready for review. It is an error for both {@link #workInProgress}
+   * and {@link #ready} to be true.
+   */
+  public boolean ready;
+
   public enum DraftHandling {
     /** Delete pending drafts on this revision only. */
     DELETE,
@@ -141,6 +142,18 @@
     return this;
   }
 
+  public ReviewInput setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+    ready = !workInProgress;
+    return this;
+  }
+
+  public ReviewInput setReady(boolean ready) {
+    this.ready = ready;
+    workInProgress = !ready;
+    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
index d772924..ff88bbe 100644
--- 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
@@ -30,4 +30,12 @@
    * additions were requested.
    */
   @Nullable public Map<String, AddReviewerResult> reviewers;
+
+  /**
+   * Boolean indicating whether the change was moved out of WIP by this review. Either true or null.
+   */
+  @Nullable public Boolean ready;
+
+  /** Error message for non-200 responses. */
+  @Nullable public String error;
 }
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
index af61481..3a33de9 100644
--- 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
@@ -25,6 +25,13 @@
    */
   @Nullable public Map<String, String> approvals;
 
+  public static ReviewerInfo byEmail(@Nullable String name, String email) {
+    ReviewerInfo info = new ReviewerInfo();
+    info.name = name;
+    info.email = email;
+    return info;
+  }
+
   public ReviewerInfo(Integer id) {
     super(id);
   }
@@ -33,4 +40,6 @@
   public String toString() {
     return username;
   }
+
+  private ReviewerInfo() {}
 }
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 f5f6fbf..2c2b3d1 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,13 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
@@ -30,13 +33,14 @@
 import java.util.Set;
 
 public interface RevisionApi {
+  @Deprecated
   void delete() throws RestApiException;
 
   String description() throws RestApiException;
 
   void description(String description) throws RestApiException;
 
-  void review(ReviewInput in) throws RestApiException;
+  ReviewResult review(ReviewInput in) throws RestApiException;
 
   void submit() throws RestApiException;
 
@@ -46,6 +50,7 @@
 
   BinaryResult submitPreview(String format) throws RestApiException;
 
+  @Deprecated
   void publish() throws RestApiException;
 
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
@@ -68,8 +73,12 @@
 
   Map<String, FileInfo> files(int parentNum) throws RestApiException;
 
+  List<String> queryFiles(String query) throws RestApiException;
+
   FileApi file(String path);
 
+  CommitInfo commit(boolean addLinks) throws RestApiException;
+
   MergeableInfo mergeable() throws RestApiException;
 
   MergeableInfo mergeableOtherBranches() throws RestApiException;
@@ -86,6 +95,17 @@
 
   List<RobotCommentInfo> robotCommentsAsList() throws RestApiException;
 
+  /**
+   * Applies the indicated fix by creating a new change edit or integrating the fix with the
+   * existing change edit. If no change edit exists before this call, the fix must refer to the
+   * current patch set. If a change edit exists, the fix must refer to the patch set on which the
+   * change edit is based.
+   *
+   * @param fixId the ID of the fix which should be applied
+   * @throws RestApiException if the fix couldn't be applied
+   */
+  EditInfo applyFix(String fixId) throws RestApiException;
+
   DraftApi createDraft(DraftInput in) throws RestApiException;
 
   DraftApi draft(String id) throws RestApiException;
@@ -109,6 +129,11 @@
 
   MergeListRequest getMergeList() throws RestApiException;
 
+  RelatedChangesInfo related() throws RestApiException;
+
+  /** Returns votes on the revision. */
+  ListMultimap<String, ApprovalInfo> votes() throws RestApiException;
+
   abstract class MergeListRequest {
     private boolean addLinks;
     private int uninterestingParent = 1;
@@ -139,13 +164,14 @@
    * interface.
    */
   class NotImplemented implements RevisionApi {
+    @Deprecated
     @Override
     public void delete() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
-    public void review(ReviewInput in) throws RestApiException {
+    public ReviewResult review(ReviewInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -159,6 +185,7 @@
       throw new NotImplementedException();
     }
 
+    @Deprecated
     @Override
     public void publish() throws RestApiException {
       throw new NotImplementedException();
@@ -225,11 +252,21 @@
     }
 
     @Override
+    public List<String> queryFiles(String query) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public FileApi file(String path) {
       throw new NotImplementedException();
     }
 
     @Override
+    public CommitInfo commit(boolean addLinks) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> comments() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -255,6 +292,11 @@
     }
 
     @Override
+    public EditInfo applyFix(String fixId) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -320,6 +362,16 @@
     }
 
     @Override
+    public RelatedChangesInfo related() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListMultimap<String, ApprovalInfo> votes() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void description(String description) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
new file mode 100644
index 0000000..fab2ec4
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -0,0 +1,23 @@
+// 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.api.config;
+
+public class AccessCheckInfo {
+  public String message;
+  // HTTP status code
+  public int status;
+
+  // for future extension, we may add inputs / results for bulk checks.
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java
new file mode 100644
index 0000000..7b7c19d
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java
@@ -0,0 +1,30 @@
+// 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.api.config;
+
+import com.google.gerrit.common.Nullable;
+
+public class AccessCheckInput {
+  public String account;
+
+  @Nullable public String ref;
+
+  public AccessCheckInput(String account, @Nullable String ref) {
+    this.account = account;
+    this.ref = ref;
+  }
+
+  public AccessCheckInput() {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
new file mode 100644
index 0000000..e44eb28
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
@@ -0,0 +1,73 @@
+// 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.api.config;
+
+import java.util.List;
+import java.util.Objects;
+
+public class ConsistencyCheckInfo {
+  public CheckAccountsResultInfo checkAccountsResult;
+  public CheckAccountExternalIdsResultInfo checkAccountExternalIdsResult;
+
+  public static class CheckAccountsResultInfo {
+    public List<ConsistencyProblemInfo> problems;
+
+    public CheckAccountsResultInfo(List<ConsistencyProblemInfo> problems) {
+      this.problems = problems;
+    }
+  }
+
+  public static class CheckAccountExternalIdsResultInfo {
+    public List<ConsistencyProblemInfo> problems;
+
+    public CheckAccountExternalIdsResultInfo(List<ConsistencyProblemInfo> problems) {
+      this.problems = problems;
+    }
+  }
+
+  public static class ConsistencyProblemInfo {
+    public enum Status {
+      ERROR,
+      WARNING,
+    }
+
+    public final Status status;
+    public final String message;
+
+    public ConsistencyProblemInfo(Status status, String message) {
+      this.status = status;
+      this.message = message;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof ConsistencyProblemInfo) {
+        ConsistencyProblemInfo other = ((ConsistencyProblemInfo) o);
+        return Objects.equals(status, other.status) && Objects.equals(message, other.message);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(status, message);
+    }
+
+    @Override
+    public String toString() {
+      return status.name() + ": " + message;
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
new file mode 100644
index 0000000..f3d927e
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
@@ -0,0 +1,24 @@
+// 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.api.config;
+
+public class ConsistencyCheckInput {
+  public CheckAccountsInput checkAccounts;
+  public CheckAccountExternalIdsInput checkAccountExternalIds;
+
+  public static class CheckAccountsInput {}
+
+  public static class CheckAccountExternalIdsInput {}
+}
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 07b3ab2..ba81698 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
@@ -34,6 +34,8 @@
 
   DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) throws RestApiException;
 
+  ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -70,5 +72,10 @@
         throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput 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 0d4742b..fe85eaa 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
@@ -109,15 +109,15 @@
   void removeMembers(String... members) throws RestApiException;
 
   /**
-   * List included groups.
+   * Lists the subgroups of this group.
    *
-   * @return included groups.
+   * @return the found subgroups
    * @throws RestApiException
    */
   List<GroupInfo> includedGroups() throws RestApiException;
 
   /**
-   * Add groups to be included in this one.
+   * Adds subgroups to this group.
    *
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
    * @throws RestApiException
@@ -125,7 +125,7 @@
   void addGroups(String... groups) throws RestApiException;
 
   /**
-   * Remove included groups from this one.
+   * Removes subgroups from this group.
    *
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
    * @throws RestApiException
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 a560fdf..47b6390 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
@@ -79,6 +79,7 @@
     private int start;
     private String substring;
     private String suggest;
+    private String regex;
 
     public List<GroupInfo> get() throws RestApiException {
       Map<String, GroupInfo> map = getAsMap();
@@ -149,6 +150,11 @@
       return this;
     }
 
+    public ListRequest withRegex(String regex) {
+      this.regex = regex;
+      return this;
+    }
+
     public ListRequest withSuggest(String suggest) {
       this.suggest = suggest;
       return this;
@@ -190,6 +196,10 @@
       return substring;
     }
 
+    public String getRegex() {
+      return regex;
+    }
+
     public String getSuggest() {
       return suggest;
     }
@@ -233,16 +243,19 @@
       return this;
     }
 
+    /** Set an option on the request, appending to existing options. */
     public QueryRequest withOption(ListGroupsOption options) {
       this.options.add(options);
       return this;
     }
 
+    /** Set options on the request, appending to existing options. */
     public QueryRequest withOptions(ListGroupsOption... options) {
       this.options.addAll(Arrays.asList(options));
       return this;
     }
 
+    /** Set options on the request, replacing existing options. */
     public QueryRequest withOptions(EnumSet<ListGroupsOption> options) {
       this.options = options;
       return this;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/PluginApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/PluginApi.java
new file mode 100644
index 0000000..b6d78a3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/PluginApi.java
@@ -0,0 +1,55 @@
+// 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.api.plugins;
+
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface PluginApi {
+  PluginInfo get() throws RestApiException;
+
+  void enable() throws RestApiException;
+
+  void disable() throws RestApiException;
+
+  void reload() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements PluginApi {
+    @Override
+    public PluginInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void enable() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void disable() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void reload() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/Plugins.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/Plugins.java
new file mode 100644
index 0000000..2828db5
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/plugins/Plugins.java
@@ -0,0 +1,128 @@
+// 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.api.plugins;
+
+import com.google.gerrit.extensions.common.InstallPluginInput;
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+
+public interface Plugins {
+
+  ListRequest list() throws RestApiException;
+
+  PluginApi name(String name) throws RestApiException;
+
+  PluginApi install(String name, InstallPluginInput input) throws RestApiException;
+
+  abstract class ListRequest {
+    private boolean all;
+    private int limit;
+    private int start;
+    private String substring;
+    private String prefix;
+    private String regex;
+
+    public List<PluginInfo> get() throws RestApiException {
+      Map<String, PluginInfo> map = getAsMap();
+      List<PluginInfo> result = new ArrayList<>(map.size());
+      for (Map.Entry<String, PluginInfo> e : map.entrySet()) {
+        result.add(e.getValue());
+      }
+      return result;
+    }
+
+    public abstract SortedMap<String, PluginInfo> getAsMap() throws RestApiException;
+
+    public ListRequest all() {
+      this.all = true;
+      return this;
+    }
+
+    public boolean getAll() {
+      return all;
+    }
+
+    public ListRequest limit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public ListRequest start(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public int getStart() {
+      return start;
+    }
+
+    public ListRequest substring(String substring) {
+      this.substring = substring;
+      return this;
+    }
+
+    public String getSubstring() {
+      return substring;
+    }
+
+    public ListRequest prefix(String prefix) {
+      this.prefix = prefix;
+      return this;
+    }
+
+    public String getPrefix() {
+      return prefix;
+    }
+
+    public ListRequest regex(String regex) {
+      this.regex = regex;
+      return this;
+    }
+
+    public String getRegex() {
+      return regex;
+    }
+  }
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements Plugins {
+    @Override
+    public ListRequest list() {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public PluginApi name(String name) {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public PluginApi install(String name, InstallPluginInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
new file mode 100644
index 0000000..a53fc74
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -0,0 +1,48 @@
+// 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.api.projects;
+
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface CommitApi {
+  CommitInfo get() throws RestApiException;
+
+  ChangeApi cherryPick(CherryPickInput input) throws RestApiException;
+
+  IncludedInInfo includedIn() throws RestApiException;
+
+  /** A default implementation for source compatibility when adding new methods to the interface. */
+  class NotImplemented implements CommitApi {
+    @Override
+    public CommitInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public IncludedInInfo includedIn() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
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
index 36c86ed..1460899 100644
--- 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
@@ -32,6 +32,10 @@
   public InheritedBooleanInfo enableSignedPush;
   public InheritedBooleanInfo requireSignedPush;
   public InheritedBooleanInfo rejectImplicitMerges;
+  public InheritedBooleanInfo privateByDefault;
+  public InheritedBooleanInfo workInProgressByDefault;
+  public InheritedBooleanInfo enableReviewerByEmail;
+  public InheritedBooleanInfo matchAuthorToCommitterDate;
   public MaxObjectSizeLimitInfo maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
@@ -41,6 +45,8 @@
   public Map<String, CommentLinkInfo> commentlinks;
   public ThemeInfo theme;
 
+  public Map<String, List<String>> extensionPanelNames;
+
   public static class InheritedBooleanInfo {
     public Boolean value;
     public InheritableBoolean configuredValue;
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
index ae81ea5..24c882c8 100644
--- 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
@@ -29,6 +29,10 @@
   public InheritableBoolean enableSignedPush;
   public InheritableBoolean requireSignedPush;
   public InheritableBoolean rejectImplicitMerges;
+  public InheritableBoolean privateByDefault;
+  public InheritableBoolean workInProgressByDefault;
+  public InheritableBoolean enableReviewerByEmail;
+  public InheritableBoolean matchAuthorToCommitterDate;
   public String maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardApi.java
new file mode 100644
index 0000000..3cde570
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardApi.java
@@ -0,0 +1,48 @@
+// 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.api.projects;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface DashboardApi {
+
+  DashboardInfo get() throws RestApiException;
+
+  DashboardInfo get(boolean inherited) throws RestApiException;
+
+  void setDefault() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements DashboardApi {
+    @Override
+    public DashboardInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DashboardInfo get(boolean inherited) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setDefault() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardInfo.java
new file mode 100644
index 0000000..f629294
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardInfo.java
@@ -0,0 +1,36 @@
+// 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.api.projects;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class DashboardInfo {
+  public String id;
+  public String project;
+  public String definingProject;
+  public String ref;
+  public String path;
+  public String description;
+  public String foreach;
+  public String url;
+
+  public Boolean isDefault;
+
+  public String title;
+  public List<DashboardSectionInfo> sections = new ArrayList<>();
+
+  public DashboardInfo() {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardSectionInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardSectionInfo.java
new file mode 100644
index 0000000..0608459
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DashboardSectionInfo.java
@@ -0,0 +1,20 @@
+// 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.api.projects;
+
+public class DashboardSectionInfo {
+  public String name;
+  public String query;
+}
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 a5221b9..86b6a27 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
@@ -16,6 +16,9 @@
 
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -36,6 +39,10 @@
 
   ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException;
 
+  ChangeInfo accessChange(ProjectAccessInput p) throws RestApiException;
+
+  AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException;
+
   ConfigInfo config() throws RestApiException;
 
   ConfigInfo config(ConfigInput in) throws RestApiException;
@@ -125,6 +132,55 @@
   TagApi tag(String ref) throws RestApiException;
 
   /**
+   * Lookup a commit by its {@code ObjectId} string.
+   *
+   * @param commit the {@code ObjectId} string.
+   * @return API for accessing the commit.
+   */
+  CommitApi commit(String commit) throws RestApiException;
+
+  /**
+   * Lookup a dashboard by its name.
+   *
+   * @param name the name.
+   * @return API for accessing the dashboard.
+   */
+  DashboardApi dashboard(String name) throws RestApiException;
+
+  /**
+   * Get the project's default dashboard.
+   *
+   * @return API for accessing the dashboard.
+   */
+  DashboardApi defaultDashboard() throws RestApiException;
+
+  /**
+   * Set the project's default dashboard.
+   *
+   * @param name the dashboard to set as default.
+   */
+  void defaultDashboard(String name) throws RestApiException;
+
+  /** Remove the project's default dashboard. */
+  void removeDefaultDashboard() throws RestApiException;
+
+  abstract class ListDashboardsRequest {
+    public abstract List<DashboardInfo> get() throws RestApiException;
+  }
+
+  ListDashboardsRequest dashboards() throws RestApiException;
+
+  /** Get the name of the branch to which {@code HEAD} points. */
+  String head() throws RestApiException;
+
+  /**
+   * Set the project's {@code HEAD}.
+   *
+   * @param head the HEAD
+   */
+  void head(String head) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -155,6 +211,21 @@
     }
 
     @Override
+    public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ChangeInfo accessChange(ProjectAccessInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ConfigInfo config() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -165,11 +236,6 @@
     }
 
     @Override
-    public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public void description(DescriptionInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -218,5 +284,45 @@
     public void deleteTags(DeleteTagsInput in) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public CommitApi commit(String commit) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DashboardApi dashboard(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public DashboardApi defaultDashboard() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListDashboardsRequest dashboards() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void defaultDashboard(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void removeDefaultDashboard() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public String head() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void head(String head) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
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 612c49c..2adb2dd 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
@@ -33,6 +33,8 @@
   public InheritableBoolean useContentMerge;
   public InheritableBoolean requireChangeId;
   public InheritableBoolean createNewChangeForAllNotInTarget;
+  public InheritableBoolean enableSignedPush;
+  public InheritableBoolean requireSignedPush;
   public String maxObjectSizeLimit;
   public Map<String, Map<String, ConfigValue>> pluginConfigValues;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java
index c7b1b94..99fc6ec 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/TagInfo.java
@@ -24,7 +24,7 @@
   public GitPerson tagger;
   public List<WebLinkInfo> webLinks;
 
-  public TagInfo(String ref, String revision, boolean canDelete, List<WebLinkInfo> webLinks) {
+  public TagInfo(String ref, String revision, Boolean canDelete, List<WebLinkInfo> webLinks) {
     this.ref = ref;
     this.revision = revision;
     this.canDelete = canDelete;
@@ -37,7 +37,7 @@
       String object,
       String message,
       GitPerson tagger,
-      boolean canDelete,
+      Boolean canDelete,
       List<WebLinkInfo> webLinks) {
     this(ref, revision, canDelete, webLinks);
     this.object = object;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeEditDetailOption.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeEditDetailOption.java
new file mode 100644
index 0000000..156b768
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ChangeEditDetailOption.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 ChangeEditDetailOption {
+  LIST_FILES,
+  DOWNLOAD_COMMANDS
+}
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 4ecde16c..83d5bd2 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
@@ -34,22 +34,6 @@
   NEW,
 
   /**
-   * Change is a draft change that only consists of draft patchsets.
-   *
-   * <p>This is a change that is not meant to be submitted or reviewed yet. If 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. 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.
-   * </ul>
-   */
-  DRAFT,
-
-  /**
    * Change is closed, and submitted to its destination branch.
    *
    * <p>Once a change has been merged, it cannot be further modified by adding a replacement patch
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 2225a99..3307997 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.Comparator;
 import java.util.Objects;
 
 public abstract class Comment {
@@ -36,7 +37,13 @@
   public String message;
   public Boolean unresolved;
 
-  public static class Range {
+  public static class Range implements Comparable<Range> {
+    private static final Comparator<Range> RANGE_COMPARATOR =
+        Comparator.<Range>comparingInt(range -> range.startLine)
+            .thenComparingInt(range -> range.startCharacter)
+            .thenComparingInt(range -> range.endLine)
+            .thenComparingInt(range -> range.endCharacter);
+
     public int startLine; // 1-based, inclusive
     public int startCharacter; // 0-based, inclusive
     public int endLine; // 1-based, exclusive
@@ -81,6 +88,11 @@
           + endCharacter
           + '}';
     }
+
+    @Override
+    public int compareTo(Range otherRange) {
+      return RANGE_COMPARATOR.compare(this, otherRange);
+    }
   }
 
   public short side() {
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
index 7192ff9..1f16d8d 100644
--- 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
@@ -157,6 +157,8 @@
   public EmailStrategy emailStrategy;
   public EmailFormat emailFormat;
   public DefaultBase defaultBaseForMerges;
+  public Boolean publishCommentsOnPush;
+  public Boolean workInProgressByDefault;
 
   public boolean isShowInfoInReviewCategory() {
     return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
@@ -225,6 +227,8 @@
     p.muteCommonPathPrefixes = true;
     p.signedOffBy = false;
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
+    p.publishCommentsOnPush = false;
+    p.workInProgressByDefault = false;
     return p;
   }
 }
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 787725c..f04685f 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
@@ -17,7 +17,7 @@
 import java.util.EnumSet;
 import java.util.Set;
 
-/** Output options available for retrieval change details. */
+/** Output options available for retrieval of change details. */
 public enum ListChangesOption {
   LABELS(0),
   DETAILED_LABELS(8),
@@ -72,7 +72,10 @@
   REVIEWER_UPDATES(19),
 
   /** Set the submittable boolean. */
-  SUBMITTABLE(20);
+  SUBMITTABLE(20),
+
+  /** If tracking Ids are included, include detailed tracking Ids info. */
+  TRACKING_IDS(21);
 
   private final int value;
 
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
index 25377a5..8375bba 100644
--- 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
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.client;
 
+import java.util.Objects;
+
 public class MenuItem {
   public final String url;
   public final String name;
@@ -39,4 +41,40 @@
     this.target = target;
     this.id = id;
   }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof MenuItem) {
+      MenuItem o = (MenuItem) obj;
+      return Objects.equals(url, o.url)
+          && Objects.equals(name, o.name)
+          && Objects.equals(target, o.target)
+          && Objects.equals(id, o.id);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(url, name, target, id);
+  }
+
+  @Override
+  public String toString() {
+    return new StringBuilder()
+        .append("MenuItem{")
+        .append("url=")
+        .append(url)
+        .append(',')
+        .append("name=")
+        .append(name)
+        .append(',')
+        .append("target=")
+        .append(target)
+        .append(',')
+        .append("id=")
+        .append(id)
+        .append('}')
+        .toString();
+  }
 }
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 3114cb9..e5bc194 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
@@ -15,7 +15,23 @@
 package com.google.gerrit.extensions.client;
 
 public enum ProjectState {
-  ACTIVE,
-  READ_ONLY,
-  HIDDEN
+  ACTIVE(true, true),
+  READ_ONLY(true, false),
+  HIDDEN(false, false);
+
+  private final boolean permitsRead;
+  private final boolean permitsWrite;
+
+  ProjectState(boolean permitsRead, boolean permitsWrite) {
+    this.permitsRead = permitsRead;
+    this.permitsWrite = permitsWrite;
+  }
+
+  public boolean permitsRead() {
+    return permitsRead;
+  }
+
+  public boolean permitsWrite() {
+    return permitsWrite;
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
index 9c64fd0..9e6770b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import static com.google.common.base.MoreObjects.toStringHelper;
+
 import com.google.common.collect.ComparisonChain;
 import java.util.Objects;
 
@@ -47,4 +49,14 @@
   public int hashCode() {
     return Objects.hash(identity, emailAddress, trusted, canDelete);
   }
+
+  @Override
+  public String toString() {
+    return toStringHelper(this)
+        .add("identity", identity)
+        .add("emailAddress", emailAddress)
+        .add("trusted", trusted)
+        .add("canDelete", canDelete)
+        .toString();
+  }
 }
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 2fb32d7..f20509b 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 public class AccountInfo {
   public Integer _accountId;
@@ -29,4 +30,34 @@
   public AccountInfo(Integer id) {
     this._accountId = id;
   }
+
+  /** To be used ONLY in connection with unregistered reviewers and CCs. */
+  public AccountInfo(String name, String email) {
+    this.name = name;
+    this.email = email;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AccountInfo) {
+      AccountInfo accountInfo = (AccountInfo) o;
+      return Objects.equals(_accountId, accountInfo._accountId)
+          && Objects.equals(name, accountInfo.name)
+          && Objects.equals(email, accountInfo.email)
+          && Objects.equals(secondaryEmails, accountInfo.secondaryEmails)
+          && Objects.equals(username, accountInfo.username)
+          && Objects.equals(avatars, accountInfo.avatars)
+          && Objects.equals(_moreAccounts, accountInfo._moreAccounts)
+          && Objects.equals(status, accountInfo.status);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        _accountId, name, email, secondaryEmails, username, avatars, _moreAccounts, status);
+  }
+
+  protected AccountInfo() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountVisibility.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountVisibility.java
new file mode 100644
index 0000000..32ec318
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountVisibility.java
@@ -0,0 +1,33 @@
+// 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.extensions.common;
+
+/** Visibility level of other accounts to a given user. */
+public enum AccountVisibility {
+  /** All accounts are visible to all users. */
+  ALL,
+
+  /** Accounts sharing a group with the given user. */
+  SAME_GROUP,
+
+  /** Accounts in a group that is visible to the given user. */
+  VISIBLE_GROUP,
+
+  /**
+   * Other accounts are not visible to the given user unless they are explicitly collaborating on a
+   * change.
+   */
+  NONE
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountsInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountsInfo.java
new file mode 100644
index 0000000..e1c2825
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountsInfo.java
@@ -0,0 +1,19 @@
+// 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.common;
+
+public class AccountsInfo {
+  public AccountVisibility visibility;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index b710121..1e822e3 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -17,7 +17,7 @@
 public class ChangeConfigInfo {
   public Boolean allowBlame;
   public Boolean showAssigneeInChangesTable;
-  public Boolean allowDrafts;
+  public Boolean disablePrivateChanges;
   public int largeChange;
   public String replyLabel;
   public String replyTooltip;
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 3803714..97f9ba1 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
@@ -23,6 +23,8 @@
 import java.util.Map;
 
 public class ChangeInfo {
+  // ActionJson#copy(List, ChangeInfo) must be adapted if new fields are added that are not
+  // protected by any ListChangesOption.
   public String id;
   public String project;
   public String branch;
@@ -35,6 +37,7 @@
   public Timestamp created;
   public Timestamp updated;
   public Timestamp submitted;
+  public AccountInfo submitter;
   public Boolean starred;
   public Collection<String> stars;
   public Boolean reviewed;
@@ -44,6 +47,10 @@
   public Integer insertions;
   public Integer deletions;
   public Integer unresolvedCommentCount;
+  public Boolean isPrivate;
+  public Boolean workInProgress;
+  public Boolean hasReviewStarted;
+  public Integer revertOf;
 
   public int _number;
 
@@ -54,6 +61,7 @@
   public Map<String, Collection<String>> permittedLabels;
   public Collection<AccountInfo> removableReviewers;
   public Map<ReviewerState, Collection<AccountInfo>> reviewers;
+  public Map<ReviewerState, Collection<AccountInfo>> pendingReviewers;
   public Collection<ReviewerUpdateInfo> reviewerUpdates;
   public Collection<ChangeMessageInfo> messages;
 
@@ -62,4 +70,6 @@
   public Boolean _moreChanges;
 
   public List<ProblemInfo> problems;
+  public List<PluginDefinedInfo> plugins;
+  public Collection<TrackingIdInfo> trackingIds;
 }
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
index b50bcf3..c8e7bca 100644
--- 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
@@ -27,10 +27,28 @@
 
   public String topic;
   public ChangeStatus status;
+  public Boolean isPrivate;
+  public Boolean workInProgress;
   public String baseChange;
   public Boolean newBranch;
   public MergeInput merge;
 
+  public ChangeInput() {}
+
+  /**
+   * Creates a new {@code ChangeInput} with the minimal attributes required for a successful
+   * creation of a new change.
+   *
+   * @param project the project name for the new change
+   * @param branch the branch name for the new change
+   * @param subject the subject (commit message) for the new change
+   */
+  public ChangeInput(String project, String branch, String subject) {
+    this.project = project;
+    this.branch = branch;
+    this.subject = subject;
+  }
+
   /** Who to send email notifications to after change is created. */
   public NotifyHandling notify = NotifyHandling.ALL;
 
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 e79918f..735b84f 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
@@ -20,6 +20,7 @@
   public String id;
   public String tag;
   public AccountInfo author;
+  public AccountInfo realAuthor;
   public Timestamp date;
   public String message;
   public Integer _revisionNumber;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitInfo.java
index a4e4071..1fd8755 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitInfo.java
@@ -14,7 +14,12 @@
 
 package com.google.gerrit.extensions.common;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
 import java.util.List;
+import java.util.Objects;
 
 public class CommitInfo {
   public String commit;
@@ -24,4 +29,41 @@
   public String subject;
   public String message;
   public List<WebLinkInfo> webLinks;
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof CommitInfo)) {
+      return false;
+    }
+    CommitInfo c = (CommitInfo) o;
+    return Objects.equals(commit, c.commit)
+        && Objects.equals(parents, c.parents)
+        && Objects.equals(author, c.author)
+        && Objects.equals(committer, c.committer)
+        && Objects.equals(subject, c.subject)
+        && Objects.equals(message, c.message)
+        && Objects.equals(webLinks, c.webLinks);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(commit, parents, author, committer, subject, message, webLinks);
+  }
+
+  @Override
+  public String toString() {
+    ToStringHelper helper = MoreObjects.toStringHelper(this).addValue(commit);
+    if (parents != null) {
+      helper.add("parents", parents.stream().map(p -> p.commit).collect(joining(", ")));
+    }
+    helper
+        .add("author", author)
+        .add("committer", committer)
+        .add("subject", subject)
+        .add("message", message);
+    if (webLinks != null) {
+      helper.add("webLinks", webLinks);
+    }
+    return helper.toString();
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitMessageInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitMessageInput.java
new file mode 100644
index 0000000..1e23cb4
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommitMessageInput.java
@@ -0,0 +1,30 @@
+// 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.common;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
+
+public class CommitMessageInput {
+  @DefaultInput public String message;
+
+  @Nullable public NotifyHandling notify;
+
+  public Map<RecipientType, NotifyInfo> notifyDetails;
+}
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 3df4b86..2511e96 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
@@ -69,6 +69,10 @@
     public List<List<Integer>> editA;
     public List<List<Integer>> editB;
 
+    // Indicates that this entry only exists because of a rebase (and not because of a real change
+    // between 'a' and 'b').
+    public Boolean dueToRebase;
+
     // a and b are actually common with this whitespace ignore setting.
     public Boolean common;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
index 46ef879..0cd5af3 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/EditInfo.java
@@ -20,6 +20,7 @@
   public CommitInfo commit;
   public int basePatchSetNumber;
   public String baseRevision;
+  public String ref;
   public Map<String, FetchInfo> fetch;
   public Map<String, FileInfo> files;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GitPerson.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GitPerson.java
index 9853417..904829c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GitPerson.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GitPerson.java
@@ -15,10 +15,42 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Objects;
 
 public class GitPerson {
   public String name;
   public String email;
   public Timestamp date;
   public int tz;
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof GitPerson)) {
+      return false;
+    }
+    GitPerson p = (GitPerson) o;
+    return Objects.equals(name, p.name)
+        && Objects.equals(email, p.email)
+        && Objects.equals(date, p.date)
+        && tz == p.tz;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, email, date, tz);
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{name="
+        + name
+        + ", email="
+        + email
+        + ", date="
+        + date
+        + ", tz="
+        + tz
+        + "}".toString();
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupBaseInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupBaseInfo.java
index 288adb6..4d35b36 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupBaseInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupBaseInfo.java
@@ -14,7 +14,14 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.base.MoreObjects;
+
 public class GroupBaseInfo {
   public String id;
   public String name;
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("name", name).add("id", id).toString();
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java
index 55fb92a..b21475c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GroupInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.sql.Timestamp;
 import java.util.List;
 
 public class GroupInfo extends GroupBaseInfo {
@@ -25,6 +26,7 @@
   public Integer groupId;
   public String owner;
   public String ownerId;
+  public Timestamp createdOn;
   public Boolean _moreGroups;
 
   // These fields are only supplied for internal groups, and only if requested.
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/InstallPluginInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/InstallPluginInput.java
new file mode 100644
index 0000000..4774ae7
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/InstallPluginInput.java
@@ -0,0 +1,23 @@
+// 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.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.RawInput;
+
+public class InstallPluginInput {
+  public @DefaultInput String url;
+  public RawInput raw;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/LabelTypeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/LabelTypeInfo.java
new file mode 100644
index 0000000..30e44b2
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/LabelTypeInfo.java
@@ -0,0 +1,22 @@
+// 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.common;
+
+import java.util.Map;
+
+public class LabelTypeInfo {
+  public Map<String, String> values;
+  public short defaultValue;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
index 2d1d840..13fc9ec 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
@@ -19,4 +19,5 @@
 public class PluginConfigInfo {
   public Boolean hasAvatars;
   public List<String> jsResourcePaths;
+  public List<String> htmlResourcePaths;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
new file mode 100644
index 0000000..e6fef0f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -0,0 +1,19 @@
+// 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.common;
+
+public class PluginDefinedInfo {
+  public String name;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginInfo.java
new file mode 100644
index 0000000..0df6235
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginInfo.java
@@ -0,0 +1,31 @@
+// 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.common;
+
+public class PluginInfo {
+  public final String id;
+  public final String version;
+  public final String indexUrl;
+  public final String filename;
+  public final Boolean disabled;
+
+  public PluginInfo(String id, String version, String indexUrl, String filename, Boolean disabled) {
+    this.id = id;
+    this.version = version;
+    this.indexUrl = indexUrl;
+    this.filename = filename;
+    this.disabled = disabled;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java
index d8e29ef..46b2599 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProjectInfo.java
@@ -26,4 +26,5 @@
   public ProjectState state;
   public Map<String, String> branches;
   public List<WebLinkInfo> webLinks;
+  public Map<String, LabelTypeInfo> labels;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PureRevertInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PureRevertInfo.java
new file mode 100644
index 0000000..7f0d7a8
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PureRevertInfo.java
@@ -0,0 +1,25 @@
+// 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.common;
+
+public class PureRevertInfo {
+  public boolean isPureRevert;
+
+  public PureRevertInfo() {}
+
+  public PureRevertInfo(boolean isPureRevert) {
+    this.isPureRevert = isPureRevert;
+  }
+}
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 a3304156c..f262901 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
@@ -19,8 +19,9 @@
 import java.util.Map;
 
 public class RevisionInfo {
+  // ActionJson#copy(List, RevisionInfo) must be adapted if new fields are added that are not
+  // protected by any ListChangesOption.
   public transient boolean isCurrent;
-  public Boolean draft;
   public ChangeKind kind;
   public int _number;
   public Timestamp created;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java
index aa4a63f..8904f0a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ServerInfo.java
@@ -17,6 +17,7 @@
 import java.util.Map;
 
 public class ServerInfo {
+  public AccountsInfo accounts;
   public AuthInfo auth;
   public ChangeConfigInfo change;
   public DownloadInfo download;
@@ -28,4 +29,5 @@
   public Map<String, String> urlAliases;
   public UserConfigInfo user;
   public ReceiveInfo receive;
+  public String defaultTheme;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SetDashboardInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SetDashboardInput.java
new file mode 100644
index 0000000..13d2b9d
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SetDashboardInput.java
@@ -0,0 +1,22 @@
+// 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.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class SetDashboardInput {
+  @DefaultInput public String id;
+  public String commitMessage;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TrackingIdInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
new file mode 100644
index 0000000..0c5ed68
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
@@ -0,0 +1,25 @@
+// 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.common;
+
+public class TrackingIdInfo {
+  public String system;
+  public String id;
+
+  public TrackingIdInfo(String system, String id) {
+    this.system = system;
+    this.id = id;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
index 4dd8f02..3af5aba 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.webui.WebLink.Target;
+import java.util.Objects;
 
 public class WebLinkInfo {
   public String name;
@@ -32,4 +33,35 @@
   public WebLinkInfo(String name, String imageUrl, String url) {
     this(name, imageUrl, url, Target.SELF);
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof WebLinkInfo)) {
+      return false;
+    }
+    WebLinkInfo i = (WebLinkInfo) o;
+    return Objects.equals(name, i.name)
+        && Objects.equals(imageUrl, i.imageUrl)
+        && Objects.equals(url, i.url)
+        && Objects.equals(target, i.target);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, imageUrl, url, target);
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{name="
+        + name
+        + ", imageUrl="
+        + imageUrl
+        + ", url="
+        + url
+        + ", target"
+        + target
+        + "}".toString();
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/BooleanCondition.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
new file mode 100644
index 0000000..950365a
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
@@ -0,0 +1,217 @@
+// 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.conditions;
+
+import com.google.common.collect.Iterables;
+import java.util.Collections;
+
+/** Delayed evaluation of a boolean condition. */
+public abstract class BooleanCondition {
+  public static final BooleanCondition TRUE = new Value(true);
+  public static final BooleanCondition FALSE = new Value(false);
+
+  public static BooleanCondition valueOf(boolean a) {
+    return a ? TRUE : FALSE;
+  }
+
+  public static BooleanCondition and(BooleanCondition a, BooleanCondition b) {
+    return a == FALSE || b == FALSE ? FALSE : new And(a, b);
+  }
+
+  public static BooleanCondition and(boolean a, BooleanCondition b) {
+    return and(valueOf(a), b);
+  }
+
+  public static BooleanCondition or(BooleanCondition a, BooleanCondition b) {
+    return a == TRUE || b == TRUE ? TRUE : new Or(a, b);
+  }
+
+  public static BooleanCondition or(boolean a, BooleanCondition b) {
+    return or(valueOf(a), b);
+  }
+
+  public static BooleanCondition not(BooleanCondition bc) {
+    return bc == TRUE ? FALSE : bc == FALSE ? TRUE : new Not(bc);
+  }
+
+  BooleanCondition() {}
+
+  /** @return evaluate the condition and return its value. */
+  public abstract boolean value();
+
+  /**
+   * Recursively collect all children of type {@code type}.
+   *
+   * @param type implementation type of the conditions to collect and return.
+   * @return non-null, unmodifiable iteration of children of type {@code type}.
+   */
+  public abstract <T> Iterable<T> children(Class<T> type);
+
+  private static final class And extends BooleanCondition {
+    private final BooleanCondition a;
+    private final BooleanCondition b;
+
+    And(BooleanCondition a, BooleanCondition b) {
+      this.a = a;
+      this.b = b;
+    }
+
+    @Override
+    public boolean value() {
+      return a.value() && b.value();
+    }
+
+    @Override
+    public <T> Iterable<T> children(Class<T> type) {
+      return Iterables.concat(a.children(type), b.children(type));
+    }
+
+    @Override
+    public int hashCode() {
+      return a.hashCode() * 31 + b.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (other instanceof And) {
+        And o = (And) other;
+        return a.equals(o.a) && b.equals(o.b);
+      }
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      return "(" + maybeTrim(a, getClass()) + " && " + maybeTrim(a, getClass()) + ")";
+    }
+  }
+
+  private static final class Or extends BooleanCondition {
+    private final BooleanCondition a;
+    private final BooleanCondition b;
+
+    Or(BooleanCondition a, BooleanCondition b) {
+      this.a = a;
+      this.b = b;
+    }
+
+    @Override
+    public boolean value() {
+      return a.value() || b.value();
+    }
+
+    @Override
+    public <T> Iterable<T> children(Class<T> type) {
+      return Iterables.concat(a.children(type), b.children(type));
+    }
+
+    @Override
+    public int hashCode() {
+      return a.hashCode() * 31 + b.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (other instanceof Or) {
+        Or o = (Or) other;
+        return a.equals(o.a) && b.equals(o.b);
+      }
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      return "(" + maybeTrim(a, getClass()) + " || " + maybeTrim(a, getClass()) + ")";
+    }
+  }
+
+  private static final class Not extends BooleanCondition {
+    private final BooleanCondition cond;
+
+    Not(BooleanCondition bc) {
+      cond = bc;
+    }
+
+    @Override
+    public boolean value() {
+      return !cond.value();
+    }
+
+    @Override
+    public <T> Iterable<T> children(Class<T> type) {
+      return cond.children(type);
+    }
+
+    @Override
+    public int hashCode() {
+      return cond.hashCode() * 31;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      return other instanceof Not ? cond.equals(((Not) other).cond) : false;
+    }
+
+    @Override
+    public String toString() {
+      return "!" + cond;
+    }
+  }
+
+  private static final class Value extends BooleanCondition {
+    private final boolean value;
+
+    Value(boolean v) {
+      value = v;
+    }
+
+    @Override
+    public boolean value() {
+      return value;
+    }
+
+    @Override
+    public <T> Iterable<T> children(Class<T> type) {
+      return Collections.emptyList();
+    }
+
+    @Override
+    public int hashCode() {
+      return value ? 1 : 0;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      return other instanceof Value ? value == ((Value) other).value : false;
+    }
+
+    @Override
+    public String toString() {
+      return Boolean.toString(value);
+    }
+  }
+
+  /** Remove leading '(' and trailing ')' if the type is the same as the parent. */
+  static String maybeTrim(BooleanCondition cond, Class<? extends BooleanCondition> type) {
+    String s = cond.toString();
+    if (cond.getClass() == type
+        && s.length() > 2
+        && s.charAt(0) == '('
+        && s.charAt(s.length() - 1) == ')') {
+      s = s.substring(1, s.length() - 1);
+    }
+    return s;
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/PrivateInternals_BooleanCondition.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/PrivateInternals_BooleanCondition.java
new file mode 100644
index 0000000..4fa932a
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/conditions/PrivateInternals_BooleanCondition.java
@@ -0,0 +1,33 @@
+// 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.conditions;
+
+import java.util.Collections;
+
+/** <b>DO NOT USE</b> */
+public final class PrivateInternals_BooleanCondition {
+  private PrivateInternals_BooleanCondition() {}
+
+  public abstract static class SubclassOnlyInCoreServer extends BooleanCondition {
+    @SuppressWarnings("unchecked")
+    @Override
+    public <T> Iterable<T> children(Class<T> type) {
+      if (type.isAssignableFrom(getClass())) {
+        return Collections.singleton((T) this);
+      }
+      return Collections.emptyList();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java
index 793a372..1630ff8 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/config/FactoryModule.java
@@ -39,7 +39,7 @@
    *
    * @param factory interface which specifies the bean factory method.
    */
-  protected void factory(final Class<?> factory) {
+  protected void factory(Class<?> factory) {
     install(new FactoryModuleBuilder().build(factory));
   }
 }
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
index fd8dac8..8dd64ed 100644
--- 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
@@ -19,8 +19,13 @@
 /** 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 indexed.
+   *
+   * @param projectName project containing the change
+   * @param id indexed change id
+   */
+  void onChangeIndexed(String projectName, 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/DraftPublishedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
deleted file mode 100644
index edbdcd8..0000000
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/DraftPublishedListener.java
+++ /dev/null
@@ -1,25 +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.events;
-
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
-/** Notified whenever a Draft is published. */
-@ExtensionPoint
-public interface DraftPublishedListener {
-  interface Event extends RevisionEvent {}
-
-  void onDraftPublished(Event event);
-}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
new file mode 100644
index 0000000..2da6ec9
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
@@ -0,0 +1,21 @@
+// 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;
+
+public interface PrivateStateChangedListener {
+  interface Event extends RevisionEvent {}
+
+  void onPrivateStateChanged(Event event);
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
new file mode 100644
index 0000000..d0e2bc1
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
@@ -0,0 +1,21 @@
+// 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;
+
+public interface WorkInProgressStateChangedListener {
+  interface Event extends RevisionEvent {}
+
+  void onWorkInProgressStateChanged(Event event);
+}
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 926818e..6030579 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
@@ -30,6 +30,8 @@
 import java.util.NoSuchElementException;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 
 /**
  * A set of members that can be modified as plugins reload.
@@ -186,7 +188,7 @@
    * @param item item to check whether or not it is contained.
    * @return {@code true} if this set contains the given item.
    */
-  public boolean contains(final T item) {
+  public boolean contains(T item) {
     Iterator<T> iterator = iterator();
     while (iterator.hasNext()) {
       T candidate = iterator.next();
@@ -203,7 +205,7 @@
    * @param item the item to add to the collection. Must not be null.
    * @return handle to remove the item at a later point in time.
    */
-  public RegistrationHandle add(final T item) {
+  public RegistrationHandle add(T item) {
     return add(Providers.of(item));
   }
 
@@ -213,7 +215,7 @@
    * @param item the item to add to the collection. Must not be null.
    * @return handle to remove the item at a later point in time.
    */
-  public RegistrationHandle add(final Provider<T> item) {
+  public RegistrationHandle add(Provider<T> item) {
     final AtomicReference<Provider<T>> ref = new AtomicReference<>(item);
     items.add(ref);
     return new RegistrationHandle() {
@@ -242,6 +244,10 @@
     return new ReloadableHandle(ref, key, item);
   }
 
+  public Stream<T> stream() {
+    return StreamSupport.stream(spliterator(), false);
+  }
+
   private class ReloadableHandle implements ReloadableRegistrationHandle<T> {
     private final AtomicReference<Provider<T>> ref;
     private final Key<T> key;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
index 5057529..50aed7d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
@@ -30,7 +30,7 @@
    * @param item the item to add to the collection. Must not be null.
    * @return handle to remove the item at a later point in time.
    */
-  public RegistrationHandle put(String pluginName, String exportName, final Provider<T> item) {
+  public RegistrationHandle put(String pluginName, String exportName, Provider<T> item) {
     final NamePair key = new NamePair(pluginName, exportName);
     items.put(key, item);
     return new RegistrationHandle() {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
index ba99a7d..994e7f2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsCreate.java
@@ -30,5 +30,5 @@
    *     returned view object, as it will not be passed.
    * @throws RestApiException the view cannot be constructed.
    */
-  <I> RestModifyView<P, I> create(P parent, IdString id) throws RestApiException;
+  RestModifyView<P, ?> create(P parent, IdString id) throws RestApiException;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
index eb30140..6b5da7c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
@@ -29,5 +29,5 @@
    * @return a view to perform the deletion.
    * @throws RestApiException the view cannot be constructed.
    */
-  <I> RestModifyView<P, I> delete(P parent, IdString id) throws RestApiException;
+  RestModifyView<P, ?> delete(P parent, IdString id) throws RestApiException;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
index ababfcb..da87d32 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AcceptsPost.java
@@ -31,5 +31,5 @@
    *     determined from the input body.
    * @throws RestApiException the view cannot be constructed.
    */
-  <I> RestModifyView<P, I> post(P parent) throws RestApiException;
+  RestModifyView<P, ?> post(P parent) throws RestApiException;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java
index 1d4cda7..0b4f459 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java
@@ -22,4 +22,12 @@
   public AuthException(String msg) {
     super(msg);
   }
+
+  /**
+   * @param msg message to return to the client.
+   * @param cause cause of this exception.
+   */
+  public AuthException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java
index 9ed83b2..c1a9ad2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/UnprocessableEntityException.java
@@ -21,4 +21,8 @@
   public UnprocessableEntityException(String msg) {
     super(msg);
   }
+
+  public UnprocessableEntityException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
 }
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 62c074e..b9d15d2 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
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.webui;
 
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 
@@ -27,6 +29,7 @@
    *     properties. If null the action will assumed unavailable and not presented. This is usually
    *     the same as {@code setVisible(false)}.
    */
+  @Nullable
   Description getDescription(R resource);
 
   /** Describes an action invokable through the web interface. */
@@ -35,8 +38,8 @@
     private String id;
     private String label;
     private String title;
-    private boolean visible = true;
-    private boolean enabled = true;
+    private BooleanCondition visible = BooleanCondition.TRUE;
+    private BooleanCondition enabled = BooleanCondition.TRUE;
 
     public String getMethod() {
       return method;
@@ -77,6 +80,10 @@
     }
 
     public boolean isVisible() {
+      return getVisibleCondition().value();
+    }
+
+    public BooleanCondition getVisibleCondition() {
       return visible;
     }
 
@@ -85,16 +92,33 @@
      * action description may not be sent to the client.
      */
     public Description setVisible(boolean visible) {
+      return setVisible(BooleanCondition.valueOf(visible));
+    }
+
+    /**
+     * Set if the action's button is visible on screen for the current client. If not visible the
+     * action description may not be sent to the client.
+     */
+    public Description setVisible(BooleanCondition visible) {
       this.visible = visible;
       return this;
     }
 
     public boolean isEnabled() {
-      return enabled && isVisible();
+      return getEnabledCondition().value();
+    }
+
+    public BooleanCondition getEnabledCondition() {
+      return BooleanCondition.and(enabled, visible);
     }
 
     /** Set if the button should be invokable (true), or greyed out (false). */
     public Description setEnabled(boolean enabled) {
+      return setEnabled(BooleanCondition.valueOf(enabled));
+    }
+
+    /** Set if the button should be invokable (true), or greyed out (false). */
+    public Description setEnabled(BooleanCondition enabled) {
       this.enabled = enabled;
       return this;
     }
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 30c6f84..ffedcfb 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
@@ -15,7 +15,7 @@
 package com.google.gerrit.gpg;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
@@ -24,7 +24,7 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index 62d0df7..c3dec61 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
@@ -30,7 +30,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
 
-  @AssistedInject
+  @Inject
   GerritPushCertificateChecker(
       GerritPublicKeyChecker.Factory keyCheckerFactory,
       GitRepositoryManager repoManager,
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 8ab5fbd..19d503f 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
@@ -338,6 +338,8 @@
       case REJECTED:
       case REJECTED_CURRENT_BRANCH:
       case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
       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 2755b91..21a5b6e 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
@@ -28,9 +28,8 @@
 /**
  * Pre-receive hook to check signed pushes.
  *
- * <p>If configured, prior to processing any push using {@link
- * com.google.gerrit.server.git.ReceiveCommits}, requires that any push certificate present must be
- * valid.
+ * <p>If configured, prior to processing any push using {@code ReceiveCommits}, requires that any
+ * push certificate present must be valid.
  */
 @Singleton
 public class SignedPushPreReceiveHook implements PreReceiveHook {
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 ba79a6f..49c7f67 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
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.api.accounts.GpgApiAdapter;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -38,15 +39,15 @@
 import org.eclipse.jgit.transport.PushCertificateParser;
 
 public class GpgApiAdapterImpl implements GpgApiAdapter {
-  private final PostGpgKeys postGpgKeys;
-  private final GpgKeys gpgKeys;
+  private final Provider<PostGpgKeys> postGpgKeys;
+  private final Provider<GpgKeys> gpgKeys;
   private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
   private final GerritPushCertificateChecker.Factory pushCertCheckerFactory;
 
   @Inject
   GpgApiAdapterImpl(
-      PostGpgKeys postGpgKeys,
-      GpgKeys gpgKeys,
+      Provider<PostGpgKeys> postGpgKeys,
+      Provider<GpgKeys> gpgKeys,
       GpgKeyApiImpl.Factory gpgKeyApiFactory,
       GerritPushCertificateChecker.Factory pushCertCheckerFactory) {
     this.postGpgKeys = postGpgKeys;
@@ -64,7 +65,7 @@
   public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
       throws RestApiException, GpgException {
     try {
-      return gpgKeys.list().apply(account);
+      return gpgKeys.get().list().apply(account);
     } catch (OrmException | PGPException | IOException e) {
       throw new GpgException(e);
     }
@@ -78,7 +79,7 @@
     in.add = add;
     in.delete = delete;
     try {
-      return postGpgKeys.apply(account, in);
+      return postGpgKeys.get().apply(account, in);
     } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
     }
@@ -88,7 +89,7 @@
   public GpgKeyApi gpgKey(AccountResource account, IdString idStr)
       throws RestApiException, GpgException {
     try {
-      return gpgKeyApiFactory.create(gpgKeys.parse(account, idStr));
+      return gpgKeyApiFactory.create(gpgKeys.get().parse(account, idStr));
     } catch (PGPException | OrmException | IOException e) {
       throw new GpgException(e);
     }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 9aa18fe..14a4c6d 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.gpg.server.GpgKey;
 import com.google.gerrit.gpg.server.GpgKeys;
 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.io.IOException;
 import org.bouncycastle.openpgp.PGPException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -36,7 +36,7 @@
   private final DeleteGpgKey delete;
   private final GpgKey rsrc;
 
-  @AssistedInject
+  @Inject
   GpgKeyApiImpl(GpgKeys.Get get, DeleteGpgKey delete, @Assisted GpgKey rsrc) {
     this.get = get;
     this.delete = delete;
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 f95cee2..212b419 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
@@ -15,18 +15,20 @@
 package com.google.gerrit.gpg.server;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.errors.EmailException;
 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.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.DeleteGpgKey.Input;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -37,25 +39,29 @@
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class DeleteGpgKey implements RestModifyView<GpgKey, Input> {
+  private static final Logger log = LoggerFactory.getLogger(DeleteGpgKey.class);
+
   public static class Input {}
 
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<ReviewDb> db;
   private final Provider<PublicKeyStore> storeProvider;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
+  private final DeleteKeySender.Factory deleteKeySenderFactory;
 
   @Inject
   DeleteGpgKey(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<ReviewDb> db,
       Provider<PublicKeyStore> storeProvider,
-      ExternalIdsUpdate.User externalIdsUpdateFactory) {
+      ExternalIdsUpdate.User externalIdsUpdateFactory,
+      DeleteKeySender.Factory deleteKeySenderFactory) {
     this.serverIdent = serverIdent;
-    this.db = db;
     this.storeProvider = storeProvider;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+    this.deleteKeySenderFactory = deleteKeySenderFactory;
   }
 
   @Override
@@ -66,7 +72,6 @@
     externalIdsUpdateFactory
         .create()
         .delete(
-            db.get(),
             rsrc.getUser().getAccountId(),
             ExternalId.Key.create(
                 SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint())));
@@ -84,6 +89,16 @@
       switch (saveResult) {
         case NO_CHANGE:
         case FAST_FORWARD:
+          try {
+            deleteKeySenderFactory
+                .create(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
+                .send();
+          } catch (EmailException e) {
+            log.error(
+                "Cannot send GPG key deletion message to {}",
+                rsrc.getUser().getAccount().getPreferredEmail(),
+                e);
+          }
           break;
         case FORCED:
         case IO_FAILURE:
@@ -93,6 +108,8 @@
         case REJECTED:
         case REJECTED_CURRENT_BRANCH:
         case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
         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 efbec80..6dd3c7e 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
@@ -14,12 +14,10 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -36,11 +34,10 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
-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.AccountResource;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -66,23 +63,23 @@
   public static final String MIME_TYPE = "application/pgp-keys";
 
   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;
+  private final ExternalIds externalIds;
 
   @Inject
   GpgKeys(
       DynamicMap<RestView<GpgKey>> views,
-      Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory) {
+      GerritPublicKeyChecker.Factory checkerFactory,
+      ExternalIds externalIds) {
     this.views = views;
-    this.db = db;
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
+    this.externalIds = externalIds;
   }
 
   @Override
@@ -198,16 +195,8 @@
     }
   }
 
-  @VisibleForTesting
-  public static FluentIterable<ExternalId> getGpgExtIds(ReviewDb db, Account.Id accountId)
-      throws OrmException {
-    return FluentIterable.from(
-            ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()))
-        .filter(in -> in.isScheme(SCHEME_GPGKEY));
-  }
-
-  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws OrmException {
-    return getGpgExtIds(db.get(), rsrc.getUser().getAccountId());
+  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException {
+    return externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
   }
 
   private static long keyId(byte[] fp) {
@@ -233,13 +222,14 @@
       Iterator<String> userIds = key.getUserIDs();
       info.userIds = ImmutableList.copyOf(userIds);
 
-      try (ByteArrayOutputStream out = new ByteArrayOutputStream(4096);
-          ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
-        // This is not exactly the key stored in the store, but is equivalent. In
-        // particular, it will have a Bouncy Castle version string. The armored
-        // stream reader in PublicKeyStore doesn't give us an easy way to extract
-        // the original ASCII armor.
-        key.encode(aout);
+      try (ByteArrayOutputStream out = new ByteArrayOutputStream(4096)) {
+        try (ArmoredOutputStream aout = new ArmoredOutputStream(out)) {
+          // This is not exactly the key stored in the store, but is equivalent. In
+          // particular, it will have a Bouncy Castle version string. The armored
+          // stream reader in PublicKeyStore doesn't give us an easy way to extract
+          // the original ASCII armor.
+          key.encode(aout);
+        }
         info.key = new String(out.toByteArray(), UTF_8);
       }
     }
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 0e3fb97..979691e 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
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -40,15 +40,16 @@
 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.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.AccountResource;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -84,31 +85,34 @@
 
   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 AddKeySender.Factory addKeySenderFactory;
+  private final DeleteKeySender.Factory deleteKeySenderFactory;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   @Inject
   PostGpgKeys(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
-      AddKeySender.Factory addKeyFactory,
+      AddKeySender.Factory addKeySenderFactory,
+      DeleteKeySender.Factory deleteKeySenderFactory,
       Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
-    this.db = db;
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.addKeyFactory = addKeyFactory;
+    this.addKeySenderFactory = addKeySenderFactory;
+    this.deleteKeySenderFactory = deleteKeySenderFactory;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
@@ -119,7 +123,7 @@
     GpgKeys.checkVisible(self, rsrc);
 
     Collection<ExternalId> existingExtIds =
-        GpgKeys.getGpgExtIds(db.get(), rsrc.getUser().getAccountId()).toList();
+        externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
     try (PublicKeyStore store = storeProvider.get()) {
       Set<Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
       List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, toRemove);
@@ -144,7 +148,7 @@
           toRemove.stream().map(fp -> toExtIdKey(fp.get())).collect(toList());
       externalIdsUpdateFactory
           .create()
-          .replace(db.get(), rsrc.getUser().getAccountId(), extIdKeysToRemove, newExtIds);
+          .replace(rsrc.getUser().getAccountId(), extIdKeysToRemove, newExtIds);
       return toJson(newKeys, toRemove, store, rsrc.getUser());
     }
   }
@@ -196,10 +200,11 @@
       throws BadRequestException, ResourceConflictException, PGPException, IOException {
     try (PublicKeyStore store = storeProvider.get()) {
       List<String> addedKeys = new ArrayList<>();
+      IdentifiedUser user = rsrc.getUser();
       for (PGPPublicKeyRing keyRing : keyRings) {
         PGPPublicKey key = keyRing.getPublicKey();
         // Don't check web of trust; admins can fill in certifications later.
-        CheckResult result = checkerFactory.create(rsrc.getUser(), store).disableTrust().check(key);
+        CheckResult result = checkerFactory.create(user, store).disableTrust().check(key);
         if (!result.isOk()) {
           throw new BadRequestException(
               String.format(
@@ -214,7 +219,7 @@
       }
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(user.newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
       cb.setCommitter(committer);
 
       RefUpdate.Result saveResult = store.save(cb);
@@ -222,13 +227,25 @@
         case NEW:
         case FAST_FORWARD:
         case FORCED:
-          try {
-            addKeyFactory.create(rsrc.getUser(), addedKeys).send();
-          } catch (EmailException e) {
-            log.error(
-                "Cannot send GPG key added message to "
-                    + rsrc.getUser().getAccount().getPreferredEmail(),
-                e);
+          if (!addedKeys.isEmpty()) {
+            try {
+              addKeySenderFactory.create(user, addedKeys).send();
+            } catch (EmailException e) {
+              log.error(
+                  "Cannot send GPG key added message to " + user.getAccount().getPreferredEmail(),
+                  e);
+            }
+          }
+          if (!toRemove.isEmpty()) {
+            try {
+              deleteKeySenderFactory
+                  .create(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
+                  .send();
+            } catch (EmailException e) {
+              log.error(
+                  "Cannot send GPG key deleted message to " + user.getAccount().getPreferredEmail(),
+                  e);
+            }
           }
           break;
         case NO_CHANGE:
@@ -239,6 +256,8 @@
         case REJECTED:
         case REJECTED_CURRENT_BRANCH:
         case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
         default:
           // TODO(dborowitz): Backoff and retry on LOCK_FAILURE.
           throw new ResourceConflictException("Failed to save public keys: " + saveResult);
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 886fdcd..07a4fe3 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
@@ -36,23 +36,22 @@
 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.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 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.TestNotesMigration;
+import com.google.gerrit.testutil.NoteDbMode;
 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 java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -71,7 +70,7 @@
 
 /** Unit tests for {@link GerritPublicKeyChecker}. */
 public class GerritPublicKeyCheckerTest {
-  @Inject private AccountCache accountCache;
+  @Inject private AccountsUpdate.Server accountsUpdate;
 
   @Inject private AccountManager accountManager;
 
@@ -105,7 +104,8 @@
         ImmutableList.of(
             Fingerprint.toString(keyB().getPublicKey().getFingerprint()),
             Fingerprint.toString(keyD().getPublicKey().getFingerprint())));
-    Injector injector = Guice.createInjector(new InMemoryModule(cfg, new TestNotesMigration()));
+    Injector injector =
+        Guice.createInjector(new InMemoryModule(cfg, NoteDbMode.newNotesMigrationFromEnv()));
 
     lifecycle = new LifecycleManager();
     lifecycle.add(injector);
@@ -115,10 +115,8 @@
     db = schemaFactory.open();
     schemaCreator.create(db);
     userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
-    Account userAccount = db.accounts().get(userId);
     // Note: does not match any key in TestKeys.
-    userAccount.setPreferredEmail("user@example.com");
-    db.accounts().update(ImmutableList.of(userAccount));
+    accountsUpdate.create().update(userId, a -> a.setPreferredEmail("user@example.com"));
     user = reloadUser();
 
     requestContext.setContext(
@@ -150,8 +148,7 @@
     return userFactory.create(id);
   }
 
-  private IdentifiedUser reloadUser() throws IOException {
-    accountCache.evict(userId);
+  private IdentifiedUser reloadUser() {
     user = userFactory.create(userId);
     return user;
   }
@@ -223,7 +220,7 @@
   @Test
   public void noExternalIds() throws Exception {
     ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
-    externalIdsUpdate.deleteAll(db, user.getAccountId());
+    externalIdsUpdate.deleteAll(user.getAccountId());
     reloadUser();
 
     TestKey key = validKeyWithSecondUserId();
@@ -237,7 +234,7 @@
     assertProblems(
         checker.check(key.getPublicKey()), Status.BAD, "Key is not associated with any users");
     externalIdsUpdate.insert(
-        db, ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
+        ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
     reloadUser();
     assertProblems(checker.check(key.getPublicKey()), Status.BAD, "No identities found for user");
   }
@@ -405,7 +402,7 @@
     cb.setCommitter(ident);
     assertThat(store.save(cb)).isAnyOf(NEW, FAST_FORWARD, FORCED);
 
-    externalIdsUpdateFactory.create().insert(db, newExtIds);
+    externalIdsUpdateFactory.create().insert(newExtIds);
   }
 
   private TestKey add(TestKey k, IdentifiedUser user) throws Exception {
@@ -430,7 +427,7 @@
   private void addExternalId(String scheme, String id, String email) throws Exception {
     externalIdsUpdateFactory
         .create()
-        .insert(db, ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
+        .insert(ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
     reloadUser();
   }
 }
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 39e2cb4..04ed1de 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
@@ -323,6 +323,8 @@
       case REJECTED:
       case REJECTED_CURRENT_BRANCH:
       case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
       default:
         throw new AssertionError(result);
     }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
index 2d0f833..7b70c6a 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/CopyableLabel.java
@@ -62,7 +62,7 @@
     return flashEnabled;
   }
 
-  public static void setFlashEnabled(final boolean on) {
+  public static void setFlashEnabled(boolean on) {
     flashEnabled = on;
   }
 
@@ -87,7 +87,7 @@
    *
    * @param str initial content
    */
-  public CopyableLabel(final String str) {
+  public CopyableLabel(String str) {
     this(str, true);
   }
 
@@ -98,7 +98,7 @@
    * @param showLabel if true, the content is shown, if false it is hidden from view and only the
    *     copy icon is displayed.
    */
-  public CopyableLabel(final String str, final boolean showLabel) {
+  public CopyableLabel(String str, boolean showLabel) {
     content = new FlowPanel();
     initWidget(content);
 
@@ -111,7 +111,7 @@
       textLabel.addClickHandler(
           new ClickHandler() {
             @Override
-            public void onClick(final ClickEvent event) {
+            public void onClick(ClickEvent event) {
               showTextBox();
             }
           });
@@ -160,7 +160,7 @@
    * @param text the new preview text, should be shorter than the original text which would be
    *     copied to the clipboard.
    */
-  public void setPreviewText(final String text) {
+  public void setPreviewText(String text) {
     if (textLabel != null) {
       textLabel.setText(text);
     }
@@ -206,7 +206,7 @@
   }
 
   @Override
-  public void setText(final String newText) {
+  public void setText(String newText) {
     text = newText;
     visibleLen = newText.length();
 
@@ -229,7 +229,7 @@
       textBox.addKeyPressHandler(
           new KeyPressHandler() {
             @Override
-            public void onKeyPress(final KeyPressEvent event) {
+            public void onKeyPress(KeyPressEvent event) {
               if (event.isControlKeyDown() || event.isMetaKeyDown()) {
                 switch (event.getCharCode()) {
                   case 'c':
@@ -237,7 +237,7 @@
                     textBox.addKeyUpHandler(
                         new KeyUpHandler() {
                           @Override
-                          public void onKeyUp(final KeyUpEvent event) {
+                          public void onKeyUp(KeyUpEvent event) {
                             Scheduler.get()
                                 .scheduleDeferred(
                                     new Command() {
@@ -256,7 +256,7 @@
       textBox.addBlurHandler(
           new BlurHandler() {
             @Override
-            public void onBlur(final BlurEvent event) {
+            public void onBlur(BlurEvent event) {
               hideTextBox();
             }
           });
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java
index 1066dd4..6ef5d7b 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/css/rebind/CssLinker.java
@@ -38,19 +38,18 @@
   }
 
   @Override
-  public ArtifactSet link(
-      final TreeLogger logger, final LinkerContext context, final ArtifactSet artifacts)
+  public ArtifactSet link(final TreeLogger logger, LinkerContext context, ArtifactSet artifacts)
       throws UnableToCompleteException {
     final ArtifactSet returnTo = new ArtifactSet();
     int index = 0;
 
     final HashMap<String, PublicResource> css = new HashMap<>();
 
-    for (final StandardStylesheetReference ssr :
+    for (StandardStylesheetReference ssr :
         artifacts.<StandardStylesheetReference>find(StandardStylesheetReference.class)) {
       css.put(ssr.getSrc(), null);
     }
-    for (final PublicResource pr : artifacts.<PublicResource>find(PublicResource.class)) {
+    for (PublicResource pr : artifacts.<PublicResource>find(PublicResource.class)) {
       if (css.containsKey(pr.getPartialPath())) {
         css.put(pr.getPartialPath(), new CssPubRsrc(name(logger, pr), pr));
       }
@@ -74,8 +73,7 @@
     return returnTo;
   }
 
-  private String name(final TreeLogger logger, final PublicResource r)
-      throws UnableToCompleteException {
+  private String name(TreeLogger logger, PublicResource r) throws UnableToCompleteException {
     byte[] out;
     try (ByteArrayOutputStream tmp = new ByteArrayOutputStream();
         InputStream in = r.getContents(logger)) {
@@ -105,13 +103,13 @@
     private static final long serialVersionUID = 1L;
     private final PublicResource src;
 
-    CssPubRsrc(final String partialPath, final PublicResource r) {
+    CssPubRsrc(String partialPath, PublicResource r) {
       super(StandardLinkerContext.class, partialPath);
       src = r;
     }
 
     @Override
-    public InputStream getContents(final TreeLogger logger) throws UnableToCompleteException {
+    public InputStream getContents(TreeLogger logger) throws UnableToCompleteException {
       return src.getContents(logger);
     }
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java
index 304d56e..5a4f6aa 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/CompoundKeyCommand.java
@@ -34,7 +34,7 @@
   }
 
   @Override
-  public void onKeyPress(final KeyPressEvent event) {
+  public void onKeyPress(KeyPressEvent event) {
     GlobalKey.temporaryWithTimeout(set);
   }
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
index 3961313..3eac789 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/GlobalKey.java
@@ -30,7 +30,7 @@
   public static final KeyPressHandler STOP_PROPAGATION =
       new KeyPressHandler() {
         @Override
-        public void onKeyPress(final KeyPressEvent event) {
+        public void onKeyPress(KeyPressEvent event) {
           event.stopPropagation();
         }
       };
@@ -50,7 +50,7 @@
           .addKeyPressHandler(
               new KeyPressHandler() {
                 @Override
-                public void onKeyPress(final KeyPressEvent event) {
+                public void onKeyPress(KeyPressEvent event) {
                   final KeyCommandSet s = active.live;
                   if (s != active.all) {
                     active.live = active.all;
@@ -78,19 +78,19 @@
       restoreGlobal =
           new CloseHandler<PopupPanel>() {
             @Override
-            public void onClose(final CloseEvent<PopupPanel> event) {
+            public void onClose(CloseEvent<PopupPanel> event) {
               active = global;
             }
           };
     }
   }
 
-  static void temporaryWithTimeout(final KeyCommandSet s) {
+  static void temporaryWithTimeout(KeyCommandSet s) {
     active.live = s;
     restoreTimer.schedule(250);
   }
 
-  public static void dialog(final PopupPanel panel) {
+  public static void dialog(PopupPanel panel) {
     initEvents();
     initDialog();
     assert panel.isShowing();
@@ -110,7 +110,7 @@
         KeyDownEvent.getType());
   }
 
-  public static HandlerRegistration addApplication(final Widget widget, final KeyCommand appKey) {
+  public static HandlerRegistration addApplication(Widget widget, KeyCommand appKey) {
     initEvents();
     final State state = stateFor(widget);
     state.add(appKey);
@@ -122,7 +122,7 @@
     };
   }
 
-  public static HandlerRegistration add(final Widget widget, final KeyCommandSet cmdSet) {
+  public static HandlerRegistration add(Widget widget, KeyCommandSet cmdSet) {
     initEvents();
     final State state = stateFor(widget);
     state.add(cmdSet);
@@ -144,7 +144,7 @@
     return global;
   }
 
-  public static void filter(final KeyCommandFilter filter) {
+  public static void filter(KeyCommandFilter filter) {
     active.filter(filter);
     if (active != global) {
       global.filter(filter);
@@ -159,7 +159,7 @@
     final KeyCommandSet all;
     KeyCommandSet live;
 
-    State(final Widget r) {
+    State(Widget r) {
       root = r;
 
       app = new KeyCommandSet(KeyConstants.I.applicationSection());
@@ -171,25 +171,25 @@
       live = all;
     }
 
-    void add(final KeyCommand k) {
+    void add(KeyCommand k) {
       app.add(k);
       all.add(k);
     }
 
-    void remove(final KeyCommand k) {
+    void remove(KeyCommand k) {
       app.remove(k);
       all.remove(k);
     }
 
-    void add(final KeyCommandSet s) {
+    void add(KeyCommandSet s) {
       all.add(s);
     }
 
-    void remove(final KeyCommandSet s) {
+    void remove(KeyCommandSet s) {
       all.remove(s);
     }
 
-    void filter(final KeyCommandFilter f) {
+    void filter(KeyCommandFilter f) {
       all.filter(f);
     }
   }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java
index 0274b9d..8222f8b 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/HidePopupPanelCommand.java
@@ -27,7 +27,7 @@
   }
 
   @Override
-  public void onKeyPress(final KeyPressEvent event) {
+  public void onKeyPress(KeyPressEvent event) {
     panel.hide();
   }
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
index 2e9b652..f1c92e0 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommand.java
@@ -25,7 +25,7 @@
   public static final int M_META = 4 << 16;
   public static final int M_SHIFT = 8 << 16;
 
-  public static boolean same(final KeyCommand a, final KeyCommand b) {
+  public static boolean same(KeyCommand a, KeyCommand b) {
     return a.getClass() == b.getClass() && a.helpText.equals(b.helpText) && a.sibling == b.sibling;
   }
 
@@ -33,11 +33,11 @@
   private final String helpText;
   KeyCommand sibling;
 
-  public KeyCommand(final int mask, final int key, final String help) {
+  public KeyCommand(int mask, int key, String help) {
     this(mask, (char) key, help);
   }
 
-  public KeyCommand(final int mask, final char key, final String help) {
+  public KeyCommand(int mask, char key, String help) {
     assert help != null;
     keyMask = mask | key;
     helpText = help;
@@ -88,12 +88,12 @@
     return b;
   }
 
-  private void modifier(final SafeHtmlBuilder b, final String name) {
+  private void modifier(SafeHtmlBuilder b, String name) {
     namedKey(b, name);
     b.append(" + ");
   }
 
-  private void namedKey(final SafeHtmlBuilder b, final String name) {
+  private void namedKey(SafeHtmlBuilder b, String name) {
     b.append('<');
     b.openSpan();
     b.setStyleName(KeyResources.I.css().helpKey());
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
index 734dd4e..90aa419 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyCommandSet.java
@@ -33,7 +33,7 @@
     this("");
   }
 
-  public KeyCommandSet(final String setName) {
+  public KeyCommandSet(String setName) {
     map = new HashMap<>();
     name = setName;
   }
@@ -42,7 +42,7 @@
     return name;
   }
 
-  public void setName(final String setName) {
+  public void setName(String setName) {
     assert setName != null;
     name = setName;
   }
@@ -62,7 +62,7 @@
     b.sibling = a;
   }
 
-  public void add(final KeyCommand k) {
+  public void add(KeyCommand k) {
     assert !map.containsKey(k.keyMask)
         : "Key " + k.describeKeyStroke().asString() + " already registered";
     if (!map.containsKey(k.keyMask)) {
@@ -70,38 +70,38 @@
     }
   }
 
-  public void remove(final KeyCommand k) {
+  public void remove(KeyCommand k) {
     assert map.get(k.keyMask) == k;
     map.remove(k.keyMask);
   }
 
-  public void add(final KeyCommandSet set) {
+  public void add(KeyCommandSet set) {
     if (sets == null) {
       sets = new ArrayList<>();
     }
     assert !sets.contains(set);
     sets.add(set);
-    for (final KeyCommand k : set.map.values()) {
+    for (KeyCommand k : set.map.values()) {
       add(k);
     }
   }
 
-  public void remove(final KeyCommandSet set) {
+  public void remove(KeyCommandSet set) {
     assert sets != null;
     assert sets.contains(set);
     sets.remove(set);
-    for (final KeyCommand k : set.map.values()) {
+    for (KeyCommand k : set.map.values()) {
       remove(k);
     }
   }
 
-  public void filter(final KeyCommandFilter filter) {
+  public void filter(KeyCommandFilter filter) {
     if (sets != null) {
-      for (final KeyCommandSet s : sets) {
+      for (KeyCommandSet s : sets) {
         s.filter(filter);
       }
     }
-    for (final Iterator<KeyCommand> i = map.values().iterator(); i.hasNext(); ) {
+    for (Iterator<KeyCommand> i = map.values().iterator(); i.hasNext(); ) {
       final KeyCommand kc = i.next();
       if (!filter.include(kc)) {
         i.remove();
@@ -120,7 +120,7 @@
   }
 
   @Override
-  public void onKeyPress(final KeyPressEvent event) {
+  public void onKeyPress(KeyPressEvent event) {
     final KeyCommand k = map.get(toMask(event));
     if (k != null) {
       event.preventDefault();
@@ -129,7 +129,7 @@
     }
   }
 
-  static int toMask(final KeyPressEvent event) {
+  static int toMask(KeyPressEvent event) {
     int mask = event.getUnicodeCharCode();
     if (mask == 0) {
       mask = event.getNativeEvent().getKeyCode();
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
index 0ec9d10..1318125 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/KeyHelpPopup.java
@@ -51,7 +51,7 @@
     closer.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             hide();
           }
         });
@@ -84,7 +84,7 @@
   }
 
   @Override
-  public void setVisible(final boolean show) {
+  public void setVisible(boolean show) {
     super.setVisible(show);
     if (show) {
       focus.setFocus(true);
@@ -92,7 +92,7 @@
   }
 
   @Override
-  public void onKeyPress(final KeyPressEvent event) {
+  public void onKeyPress(KeyPressEvent event) {
     if (KeyCommandSet.toMask(event) == ShowHelpCommand.INSTANCE.keyMask) {
       // Block the '?' key from triggering us to show right after
       // we just hide ourselves.
@@ -104,16 +104,16 @@
   }
 
   @Override
-  public void onKeyDown(final KeyDownEvent event) {
+  public void onKeyDown(KeyDownEvent event) {
     if (event.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
       hide();
     }
   }
 
-  private void populate(final Grid lists) {
+  private void populate(Grid lists) {
     int[] end = new int[5];
     int column = 0;
-    for (final KeyCommandSet set : combinedSetsByName()) {
+    for (KeyCommandSet set : combinedSetsByName()) {
       int row = end[column];
       row = formatGroup(lists, row, column, set);
       end[column] = row;
@@ -131,7 +131,7 @@
    */
   private static Collection<KeyCommandSet> combinedSetsByName() {
     LinkedHashMap<String, KeyCommandSet> byName = new LinkedHashMap<>();
-    for (final KeyCommandSet set : GlobalKey.active.all.getSets()) {
+    for (KeyCommandSet set : GlobalKey.active.all.getSets()) {
       KeyCommandSet v = byName.get(set.getName());
       if (v == null) {
         v = new KeyCommandSet(set.getName());
@@ -142,7 +142,7 @@
     return byName.values();
   }
 
-  private int formatGroup(final Grid lists, int row, final int col, final KeyCommandSet set) {
+  private int formatGroup(Grid lists, int row, int col, KeyCommandSet set) {
     if (set.isEmpty()) {
       return row;
     }
@@ -157,8 +157,7 @@
     return formatKeys(lists, row, col, set, null);
   }
 
-  private int formatKeys(
-      final Grid lists, int row, final int col, final KeyCommandSet set, final SafeHtml prefix) {
+  private int formatKeys(final Grid lists, int row, int col, KeyCommandSet set, SafeHtml prefix) {
     final CellFormatter fmt = lists.getCellFormatter();
     final List<KeyCommand> keys = sort(set);
     if (lists.getRowCount() < row + keys.size()) {
@@ -228,7 +227,7 @@
     return row;
   }
 
-  private List<KeyCommand> sort(final KeyCommandSet set) {
+  private List<KeyCommand> sort(KeyCommandSet set) {
     final List<KeyCommand> keys = new ArrayList<>(set.getKeys());
     Collections.sort(
         keys,
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextBox.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextBox.java
index 86402e1..1392675 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextBox.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/NpTextBox.java
@@ -22,7 +22,7 @@
     addKeyPressHandler(GlobalKey.STOP_PROPAGATION);
   }
 
-  public NpTextBox(final Element element) {
+  public NpTextBox(Element element) {
     super(element);
     addKeyPressHandler(GlobalKey.STOP_PROPAGATION);
   }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
index c2272c5..08217f4 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/globalkey/client/ShowHelpCommand.java
@@ -40,7 +40,7 @@
   }
 
   @Override
-  public void onKeyPress(final KeyPressEvent event) {
+  public void onKeyPress(KeyPressEvent event) {
     if (current != null) {
       // Already open? Close the dialog.
       //
@@ -52,7 +52,7 @@
     help.addCloseHandler(
         new CloseHandler<PopupPanel>() {
           @Override
-          public void onClose(final CloseEvent<PopupPanel> event) {
+          public void onClose(CloseEvent<PopupPanel> event) {
             current = null;
             BUS.fireEvent(new FocusEvent() {});
           }
@@ -61,7 +61,7 @@
     help.setPopupPositionAndShow(
         new PositionCallback() {
           @Override
-          public void setPosition(final int pWidth, final int pHeight) {
+          public void setPosition(int pWidth, int pHeight) {
             final int left = (Window.getClientWidth() - pWidth) >> 1;
             final int wLeft = Window.getScrollLeft();
             final int wTop = Window.getScrollTop();
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java
index bc18323..f133e4d 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/progress/client/ProgressBar.java
@@ -41,7 +41,7 @@
   }
 
   /** Create a bar displaying the specified message. */
-  public ProgressBar(final String text) {
+  public ProgressBar(String text) {
     if (text == null || text.length() == 0) {
       callerText = "";
     } else {
@@ -68,7 +68,7 @@
   }
 
   /** Update the bar's percent completion. */
-  public void setValue(final int pComplete) {
+  public void setValue(int pComplete) {
     assert 0 <= pComplete && pComplete <= 100;
     value = pComplete;
     bar.setWidth(2 * pComplete + "px");
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 eb141f15..c93a78b 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
@@ -38,7 +38,7 @@
   private Tag tag = ANY;
   private int live;
 
-  void reset(final String tagName) {
+  void reset(String tagName) {
     tag = TAGS.get(tagName.toLowerCase());
     if (tag == null) {
       tag = ANY;
@@ -46,7 +46,7 @@
     live = 0;
   }
 
-  void onto(final Buffer raw, final SafeHtmlBuilder esc) {
+  void onto(Buffer raw, SafeHtmlBuilder esc) {
     for (int i = 0; i < live; i++) {
       final String v = values.get(i);
       if (v.length() > 0) {
@@ -70,7 +70,7 @@
     return "";
   }
 
-  void set(String name, final String value) {
+  void set(String name, String value) {
     name = name.toLowerCase();
     tag.assertSafe(name, value);
 
@@ -91,7 +91,7 @@
     }
   }
 
-  private static void assertNotJavascriptUrl(final String value) {
+  private static void assertNotJavascriptUrl(String value) {
     if (value.startsWith("#")) {
       // common in GWT, and safe, so bypass further checks
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferDirect.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferDirect.java
index 83abd5d..c6e1d30 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferDirect.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferDirect.java
@@ -22,37 +22,37 @@
   }
 
   @Override
-  public void append(final boolean v) {
+  public void append(boolean v) {
     strbuf.append(v);
   }
 
   @Override
-  public void append(final char v) {
+  public void append(char v) {
     strbuf.append(v);
   }
 
   @Override
-  public void append(final int v) {
+  public void append(int v) {
     strbuf.append(v);
   }
 
   @Override
-  public void append(final long v) {
+  public void append(long v) {
     strbuf.append(v);
   }
 
   @Override
-  public void append(final float v) {
+  public void append(float v) {
     strbuf.append(v);
   }
 
   @Override
-  public void append(final double v) {
+  public void append(double v) {
     strbuf.append(v);
   }
 
   @Override
-  public void append(final String v) {
+  public void append(String v) {
     strbuf.append(v);
   }
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java
index e3aed55..bdd9801 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/BufferSealElement.java
@@ -17,42 +17,42 @@
 final class BufferSealElement implements Buffer {
   private final SafeHtmlBuilder shb;
 
-  BufferSealElement(final SafeHtmlBuilder safeHtmlBuilder) {
+  BufferSealElement(SafeHtmlBuilder safeHtmlBuilder) {
     shb = safeHtmlBuilder;
   }
 
   @Override
-  public void append(final boolean v) {
+  public void append(boolean v) {
     shb.sealElement().append(v);
   }
 
   @Override
-  public void append(final char v) {
+  public void append(char v) {
     shb.sealElement().append(v);
   }
 
   @Override
-  public void append(final double v) {
+  public void append(double v) {
     shb.sealElement().append(v);
   }
 
   @Override
-  public void append(final float v) {
+  public void append(float v) {
     shb.sealElement().append(v);
   }
 
   @Override
-  public void append(final int v) {
+  public void append(int v) {
     shb.sealElement().append(v);
   }
 
   @Override
-  public void append(final long v) {
+  public void append(long v) {
     shb.sealElement().append(v);
   }
 
   @Override
-  public void append(final String v) {
+  public void append(String v) {
     shb.sealElement().append(v);
   }
 
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 25cad1d..ef80cdb 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
@@ -45,11 +45,11 @@
         request,
         new Callback() {
           @Override
-          public void onSuggestionsReady(final Request request, final Response response) {
+          public void onSuggestionsReady(Request request, Response response) {
             final String qpat = getQueryPattern(request.getQuery());
             final boolean html = isHTML();
             final ArrayList<Suggestion> r = new ArrayList<>();
-            for (final Suggestion s : response.getSuggestions()) {
+            for (Suggestion s : response.getSuggestions()) {
               r.add(new BoldSuggestion(qpat, s, html));
             }
             cb.onSuggestionsReady(request, new Response(r));
@@ -57,7 +57,7 @@
         });
   }
 
-  protected String getQueryPattern(final String query) {
+  protected String getQueryPattern(String query) {
     return query;
   }
 
@@ -77,7 +77,7 @@
     private final Suggestion suggestion;
     private final String displayString;
 
-    BoldSuggestion(final String qstr, final Suggestion s, final boolean html) {
+    BoldSuggestion(String qstr, Suggestion s, boolean html) {
       suggestion = s;
 
       String ds = s.getDisplayString();
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 9161652a..5b3b9b6 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
@@ -28,8 +28,9 @@
 import java.util.List;
 
 /** Immutable string safely placed as HTML without further escaping. */
-@SuppressWarnings("serial")
 public abstract class SafeHtml implements com.google.gwt.safehtml.shared.SafeHtml {
+  private static final long serialVersionUID = 1L;
+
   public static final SafeHtmlResources RESOURCES;
 
   static {
@@ -79,17 +80,17 @@
   }
 
   /** @return the existing HTML property of a widget. */
-  public static SafeHtml get(final HasHTML t) {
+  public static SafeHtml get(HasHTML t) {
     return new SafeHtmlString(t.getHTML());
   }
 
   /** @return the existing HTML text, wrapped in a safe buffer. */
-  public static SafeHtml asis(final String htmlText) {
+  public static SafeHtml asis(String htmlText) {
     return new SafeHtmlString(htmlText);
   }
 
   /** Set the HTML property of a widget. */
-  public static <T extends HasHTML> T set(final T e, final SafeHtml str) {
+  public static <T extends HasHTML> T set(T e, SafeHtml str) {
     e.setHTML(str.asString());
     return e;
   }
@@ -106,13 +107,12 @@
   }
 
   /** @return the existing inner HTML of a table cell. */
-  public static SafeHtml get(final HTMLTable t, final int row, final int col) {
+  public static SafeHtml get(HTMLTable t, int row, int col) {
     return new SafeHtmlString(t.getHTML(row, col));
   }
 
   /** Set the inner HTML of a table cell. */
-  public static <T extends HTMLTable> T set(
-      final T t, final int row, final int col, final SafeHtml str) {
+  public static <T extends HTMLTable> T set(final T t, int row, int col, SafeHtml str) {
     t.setHTML(row, col, str.asString());
     return t;
   }
@@ -140,13 +140,13 @@
    */
   public SafeHtml wikify() {
     final SafeHtmlBuilder r = new SafeHtmlBuilder();
-    for (final String p : linkify().asString().split("\n\n")) {
+    for (String p : linkify().asString().split("\n\n")) {
       if (isQuote(p)) {
         wikifyQuote(r, p);
 
       } else if (isPreFormat(p)) {
         r.openElement("p");
-        for (final String line : p.split("\n")) {
+        for (String line : p.split("\n")) {
           r.openSpan();
           r.setStyleName(RESOURCES.css().wikiPreFormat());
           r.append(asis(line));
@@ -167,7 +167,7 @@
     return r.toSafeHtml();
   }
 
-  private void wikifyList(final SafeHtmlBuilder r, final String p) {
+  private void wikifyList(SafeHtmlBuilder r, String p) {
     boolean in_ul = false;
     boolean in_p = false;
     for (String line : p.split("\n")) {
@@ -232,11 +232,11 @@
     return p.startsWith("&gt; ") || p.startsWith(" &gt; ");
   }
 
-  private static boolean isPreFormat(final String p) {
+  private static boolean isPreFormat(String p) {
     return p.contains("\n ") || p.contains("\n\t") || p.startsWith(" ") || p.startsWith("\t");
   }
 
-  private static boolean isList(final String p) {
+  private static boolean isList(String p) {
     return p.contains("\n- ") || p.contains("\n* ") || p.startsWith("- ") || p.startsWith("* ");
   }
 
@@ -252,7 +252,7 @@
    *     {@code $<i>n</i>}.
    * @return a new string, after the replacement has been made.
    */
-  public SafeHtml replaceFirst(final String regex, final String repl) {
+  public SafeHtml replaceFirst(String regex, String repl) {
     return new SafeHtmlString(asString().replaceFirst(regex, repl));
   }
 
@@ -268,7 +268,7 @@
    *     {@code $<i>n</i>}.
    * @return a new string, after the replacements have been made.
    */
-  public SafeHtml replaceAll(final String regex, final String repl) {
+  public SafeHtml replaceAll(String regex, String repl) {
     return new SafeHtmlString(asString().replaceAll(regex, repl));
   }
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
index f54149b..cde0f2a 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilder.java
@@ -17,8 +17,9 @@
 import com.google.gwt.core.client.GWT;
 
 /** Safely constructs a {@link SafeHtml}, escaping user provided content. */
-@SuppressWarnings("serial")
 public class SafeHtmlBuilder extends SafeHtml {
+  private static final long serialVersionUID = 1L;
+
   private static final Impl impl;
 
   static {
@@ -49,12 +50,12 @@
     return !isEmpty();
   }
 
-  public SafeHtmlBuilder append(final boolean in) {
+  public SafeHtmlBuilder append(boolean in) {
     cb.append(in);
     return this;
   }
 
-  public SafeHtmlBuilder append(final char in) {
+  public SafeHtmlBuilder append(char in) {
     switch (in) {
       case '&':
         cb.append("&amp;");
@@ -83,22 +84,22 @@
     return this;
   }
 
-  public SafeHtmlBuilder append(final int in) {
+  public SafeHtmlBuilder append(int in) {
     cb.append(in);
     return this;
   }
 
-  public SafeHtmlBuilder append(final long in) {
+  public SafeHtmlBuilder append(long in) {
     cb.append(in);
     return this;
   }
 
-  public SafeHtmlBuilder append(final float in) {
+  public SafeHtmlBuilder append(float in) {
     cb.append(in);
     return this;
   }
 
-  public SafeHtmlBuilder append(final double in) {
+  public SafeHtmlBuilder append(double in) {
     cb.append(in);
     return this;
   }
@@ -112,7 +113,7 @@
   }
 
   /** Append already safe HTML as-is, avoiding double escaping. */
-  public SafeHtmlBuilder append(final SafeHtml in) {
+  public SafeHtmlBuilder append(SafeHtml in) {
     if (in != null) {
       cb.append(in.asString());
     }
@@ -120,7 +121,7 @@
   }
 
   /** Append the string, escaping unsafe characters. */
-  public SafeHtmlBuilder append(final String in) {
+  public SafeHtmlBuilder append(String in) {
     if (in != null) {
       impl.escapeStr(this, in);
     }
@@ -128,7 +129,7 @@
   }
 
   /** Append the string, escaping unsafe characters. */
-  public SafeHtmlBuilder append(final StringBuilder in) {
+  public SafeHtmlBuilder append(StringBuilder in) {
     if (in != null) {
       append(in.toString());
     }
@@ -136,7 +137,7 @@
   }
 
   /** Append the string, escaping unsafe characters. */
-  public SafeHtmlBuilder append(final StringBuffer in) {
+  public SafeHtmlBuilder append(StringBuffer in) {
     if (in != null) {
       append(in.toString());
     }
@@ -144,7 +145,7 @@
   }
 
   /** Append the result of toString(), escaping unsafe characters. */
-  public SafeHtmlBuilder append(final Object in) {
+  public SafeHtmlBuilder append(Object in) {
     if (in != null) {
       append(in.toString());
     }
@@ -152,7 +153,7 @@
   }
 
   /** Append the string, escaping unsafe characters. */
-  public SafeHtmlBuilder append(final CharSequence in) {
+  public SafeHtmlBuilder append(CharSequence in) {
     if (in != null) {
       escapeCS(this, in);
     }
@@ -167,7 +168,7 @@
    *
    * @param tagName name of the HTML element to open.
    */
-  public SafeHtmlBuilder openElement(final String tagName) {
+  public SafeHtmlBuilder openElement(String tagName) {
     assert isElementName(tagName);
     cb.append("<");
     cb.append(tagName);
@@ -187,7 +188,7 @@
    * @return the attribute value, as a string. The empty string if the attribute has not been
    *     assigned a value. The returned string is the raw (unescaped) value.
    */
-  public String getAttribute(final String name) {
+  public String getAttribute(String name) {
     assert isAttributeName(name);
     assert cb == sBuf;
     return att.get(name);
@@ -200,7 +201,7 @@
    * @param value value to assign; any existing value is replaced. The value is escaped (if
    *     necessary) during the assignment.
    */
-  public SafeHtmlBuilder setAttribute(final String name, final String value) {
+  public SafeHtmlBuilder setAttribute(String name, String value) {
     assert isAttributeName(name);
     assert cb == sBuf;
     att.set(name, value != null ? value : "");
@@ -213,7 +214,7 @@
    * @param name name of the attribute to set.
    * @param value value to assign, any existing value is replaced.
    */
-  public SafeHtmlBuilder setAttribute(final String name, final int value) {
+  public SafeHtmlBuilder setAttribute(String name, int value) {
     return setAttribute(name, String.valueOf(value));
   }
 
@@ -227,7 +228,7 @@
    * @param name name of the attribute to append onto.
    * @param value additional value to append.
    */
-  public SafeHtmlBuilder appendAttribute(final String name, String value) {
+  public SafeHtmlBuilder appendAttribute(String name, String value) {
     if (value != null && value.length() > 0) {
       final String e = getAttribute(name);
       return setAttribute(name, e.length() > 0 ? e + " " + value : value);
@@ -236,17 +237,17 @@
   }
 
   /** Set the height attribute of the current element. */
-  public SafeHtmlBuilder setHeight(final int height) {
+  public SafeHtmlBuilder setHeight(int height) {
     return setAttribute("height", height);
   }
 
   /** Set the width attribute of the current element. */
-  public SafeHtmlBuilder setWidth(final int width) {
+  public SafeHtmlBuilder setWidth(int width) {
     return setAttribute("width", width);
   }
 
   /** Set the CSS class name for this element. */
-  public SafeHtmlBuilder setStyleName(final String style) {
+  public SafeHtmlBuilder setStyleName(String style) {
     assert isCssName(style);
     return setAttribute("class", style);
   }
@@ -256,7 +257,7 @@
    *
    * <p>If no CSS class name has been specified yet, this method initializes it to the single name.
    */
-  public SafeHtmlBuilder addStyleName(final String style) {
+  public SafeHtmlBuilder addStyleName(String style) {
     assert isCssName(style);
     return appendAttribute("class", style);
   }
@@ -281,7 +282,7 @@
   }
 
   /** Append a closing tag for the named element. */
-  public SafeHtmlBuilder closeElement(final String name) {
+  public SafeHtmlBuilder closeElement(String name) {
     assert isElementName(name);
     cb.append("</");
     cb.append(name);
@@ -362,7 +363,7 @@
   }
 
   /** Append "&lt;param name=... value=... /&gt;". */
-  public SafeHtmlBuilder paramElement(final String name, final String value) {
+  public SafeHtmlBuilder paramElement(String name, String value) {
     openElement("param");
     setAttribute("name", name);
     setAttribute("value", value);
@@ -379,21 +380,21 @@
     return cb.toString();
   }
 
-  private static void escapeCS(final SafeHtmlBuilder b, final CharSequence in) {
+  private static void escapeCS(SafeHtmlBuilder b, CharSequence in) {
     for (int i = 0; i < in.length(); i++) {
       b.append(in.charAt(i));
     }
   }
 
-  private static boolean isElementName(final String name) {
+  private static boolean isElementName(String name) {
     return name.matches("^[a-zA-Z][a-zA-Z0-9_-]*$");
   }
 
-  private static boolean isAttributeName(final String name) {
+  private static boolean isAttributeName(String name) {
     return isElementName(name);
   }
 
-  private static boolean isCssName(final String name) {
+  private static boolean isCssName(String name) {
     return isElementName(name);
   }
 
@@ -403,14 +404,14 @@
 
   private static class ServerImpl extends Impl {
     @Override
-    void escapeStr(final SafeHtmlBuilder b, final String in) {
+    void escapeStr(SafeHtmlBuilder b, String in) {
       SafeHtmlBuilder.escapeCS(b, in);
     }
   }
 
   private static class ClientImpl extends Impl {
     @Override
-    void escapeStr(final SafeHtmlBuilder b, final String in) {
+    void escapeStr(SafeHtmlBuilder b, String in) {
       b.cb.append(escape(in));
     }
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
index 57392bf..5335170 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/SafeHtmlString.java
@@ -14,11 +14,12 @@
 
 package com.google.gwtexpui.safehtml.client;
 
-@SuppressWarnings("serial")
 class SafeHtmlString extends SafeHtml {
+  private static final long serialVersionUID = 1L;
+
   private final String html;
 
-  SafeHtmlString(final String h) {
+  SafeHtmlString(String h) {
     html = h;
   }
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java
index 4e39c1f..571f72d 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/server/CacheControlFilter.java
@@ -48,14 +48,13 @@
  */
 public class CacheControlFilter implements Filter {
   @Override
-  public void init(final FilterConfig config) {}
+  public void init(FilterConfig config) {}
 
   @Override
   public void destroy() {}
 
   @Override
-  public void doFilter(
-      final ServletRequest sreq, final ServletResponse srsp, final FilterChain chain)
+  public void doFilter(final ServletRequest sreq, ServletResponse srsp, FilterChain chain)
       throws IOException, ServletException {
     final HttpServletRequest req = (HttpServletRequest) sreq;
     final HttpServletResponse rsp = (HttpServletResponse) srsp;
@@ -70,7 +69,7 @@
     chain.doFilter(req, rsp);
   }
 
-  private static boolean cacheForever(final String pathInfo, final HttpServletRequest req) {
+  private static boolean cacheForever(String pathInfo, HttpServletRequest req) {
     if (pathInfo.endsWith(".cache.html")
         || pathInfo.endsWith(".cache.gif")
         || pathInfo.endsWith(".cache.png")
@@ -87,14 +86,14 @@
     return false;
   }
 
-  private static boolean nocache(final String pathInfo) {
+  private static boolean nocache(String pathInfo) {
     if (pathInfo.endsWith(".nocache.js")) {
       return true;
     }
     return false;
   }
 
-  private static String pathInfo(final HttpServletRequest req) {
+  private static String pathInfo(HttpServletRequest req) {
     final String uri = req.getRequestURI();
     final String ctx = req.getContextPath();
     return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
index 7c165e5..fdaf861 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/AutoCenterDialogBox.java
@@ -28,11 +28,11 @@
     this(false);
   }
 
-  public AutoCenterDialogBox(final boolean autoHide) {
+  public AutoCenterDialogBox(boolean autoHide) {
     this(autoHide, true);
   }
 
-  public AutoCenterDialogBox(final boolean autoHide, final boolean modal) {
+  public AutoCenterDialogBox(boolean autoHide, boolean modal) {
     super(autoHide, modal);
   }
 
@@ -43,7 +43,7 @@
           Window.addResizeHandler(
               new ResizeHandler() {
                 @Override
-                public void onResize(final ResizeEvent event) {
+                public void onResize(ResizeEvent event) {
                   final int w = event.getWidth();
                   final int h = event.getHeight();
                   AutoCenterDialogBox.this.onResize(w, h);
@@ -71,7 +71,7 @@
    * @param width new browser window width
    * @param height new browser window height
    */
-  protected void onResize(final int width, final int height) {
+  protected void onResize(int width, int height) {
     if (isAttached()) {
       center();
     }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java
index ca712c3..4614546 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/user/client/ViewSite.java
@@ -51,7 +51,7 @@
    *
    * @param view the next view to display.
    */
-  public void setView(final V view) {
+  public void setView(V view) {
     if (next != null) {
       main.remove(next);
     }
@@ -67,10 +67,10 @@
    *
    * @param view the view being displayed.
    */
-  protected void onShowView(final V view) {}
+  protected void onShowView(V view) {}
 
   @SuppressWarnings("unchecked")
-  final void swap(final View v) {
+  final void swap(View v) {
     if (next != null && next.getWidget() == v) {
       if (current != null) {
         main.remove(current);
diff --git a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
index 17b0a4d..9a2dbe3 100644
--- a/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
+++ b/gerrit-gwtexpui/src/test/java/com/google/gwtexpui/safehtml/client/SafeHtmlBuilderTest.java
@@ -280,11 +280,11 @@
     new SafeHtmlBuilder().openElement("form").setAttribute("action", href);
   }
 
-  private static String escape(final char c) {
+  private static String escape(char c) {
     return new SafeHtmlBuilder().append(c).asString();
   }
 
-  private static String escape(final String c) {
+  private static String escape(String c) {
     return new SafeHtmlBuilder().append(c).asString();
   }
 }
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 32f79d7..4df2f5f 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
@@ -90,7 +90,7 @@
   }
 
   /** Format a date using the locale's medium length format. */
-  public String mediumFormat(final Date dt) {
+  public String mediumFormat(Date dt) {
     if (dt == null) {
       return "";
     }
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 0de8b68..866d74f 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
@@ -134,10 +134,17 @@
 
   private native String submittedRaw() /*-{ return this.submitted; }-*/;
 
+  public final native AccountInfo submitter() /*-{ return this.submitter; }-*/;
+
   public final native boolean starred() /*-{ return this.starred ? true : false; }-*/;
 
   public final native boolean reviewed() /*-{ return this.reviewed ? true : false; }-*/;
 
+  public final native boolean isPrivate() /*-{ return this.is_private ? true : false; }-*/;
+
+  public final native boolean
+      isWorkInProgress() /*-{ return this.work_in_progress ? true : false; }-*/;
+
   public final native NativeMap<LabelInfo> allLabels() /*-{ return this.labels; }-*/;
 
   public final native LabelInfo label(String n) /*-{ return this.labels[n]; }-*/;
@@ -410,8 +417,6 @@
 
     public final native String name() /*-{ return this.name; }-*/;
 
-    public final native boolean draft() /*-{ return this.draft || false; }-*/;
-
     public final native AccountInfo uploader() /*-{ return this.uploader; }-*/;
 
     public final native boolean isEdit() /*-{ return this._number == 0; }-*/;
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
index 23e1a93..fbdf52c 100644
--- 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
@@ -148,6 +148,12 @@
 
   private native String defaultBaseForMergesRaw() /*-{ return this.default_base_for_merges }-*/;
 
+  public final native boolean
+      publishCommentsOnPush() /*-{ return this.publish_comments_on_push || false }-*/;
+
+  public final native boolean
+      workInProgressByDefault() /*-{ return this.work_in_progress_by_default || false }-*/;
+
   public final native JsArray<TopMenuItem> my() /*-{ return this.my; }-*/;
 
   public final native void changesPerPage(int n) /*-{ this.changes_per_page = n }-*/;
@@ -224,6 +230,12 @@
 
   private native void defaultBaseForMergesRaw(String b) /*-{ this.default_base_for_merges = b }-*/;
 
+  public final native void publishCommentsOnPush(
+      boolean p) /*-{ this.publish_comments_on_push = p }-*/;
+
+  public final native void workInProgressByDefault(
+      boolean p) /*-{ this.work_in_progress_by_default = p }-*/;
+
   public final void setMyMenus(List<TopMenuItem> myMenus) {
     initMy();
     for (TopMenuItem n : myMenus) {
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 dcd1cf1..d3274e6 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
@@ -60,8 +60,6 @@
   protected ServerInfo() {}
 
   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; }-*/;
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
index 43ff60c..4b17068 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/NativeMap.java
@@ -40,7 +40,7 @@
 
   /** Loop through the result map and set asProperty on the children. */
   public static <T extends JavaScriptObject, M extends NativeMap<T>>
-      AsyncCallback<M> copyKeysIntoChildren(final String asProperty, AsyncCallback<M> callback) {
+      AsyncCallback<M> copyKeysIntoChildren(String asProperty, AsyncCallback<M> callback) {
     return new TransformCallback<M, M>(callback) {
       @Override
       protected M transform(M result) {
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 a4b90c3..e0bca0e 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
@@ -38,7 +38,7 @@
 
   public native String asString() /*-{ return this.s; }-*/;
 
-  public static AsyncCallback<NativeString> unwrap(final AsyncCallback<String> cb) {
+  public static AsyncCallback<NativeString> unwrap(AsyncCallback<String> cb) {
     return new AsyncCallback<NativeString>() {
       @Override
       public void onSuccess(NativeString result) {
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
index ebaa63b..1421386 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/rpc/Natives.java
@@ -35,7 +35,7 @@
     return Collections.emptySet();
   }
 
-  public static List<String> asList(final JsArrayString arr) {
+  public static List<String> asList(JsArrayString arr) {
     if (arr == null) {
       return null;
     }
@@ -59,7 +59,7 @@
     };
   }
 
-  public static <T extends JavaScriptObject> List<T> asList(final JsArray<T> arr) {
+  public static <T extends JavaScriptObject> List<T> asList(JsArray<T> arr) {
     if (arr == null) {
       return null;
     }
diff --git a/gerrit-gwtui/BUILD b/gerrit-gwtui/BUILD
index 27d1fc3..47a59c6 100644
--- a/gerrit-gwtui/BUILD
+++ b/gerrit-gwtui/BUILD
@@ -4,6 +4,7 @@
     "gwt_genrule",
     "gwt_user_agent_permutations",
 )
+load("//tools/bzl:java.bzl", "java_library2")
 load("//tools/bzl:junit.bzl", "junit_tests")
 load("//tools/bzl:license.bzl", "license_test")
 
@@ -20,6 +21,19 @@
 
 gwt_user_agent_permutations()
 
+java_library2(
+    name = "client-lib",
+    srcs = glob(["src/main/**/*.java"]),
+    exported_deps = [":ui_module"],
+    resources = glob(["src/main/**/*"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-gwtui-common:client-lib",
+        "//lib/gwt:dev",
+        "//lib/gwt:user",
+    ],
+)
+
 license_test(
     name = "ui_module_license_test",
     target = ":ui_module",
@@ -34,6 +48,7 @@
         "//gerrit-common:client",
         "//gerrit-extension-api:client",
         "//lib:junit",
+        "//lib:truth",
         "//lib/gwt:dev",
         "//lib/gwt:user",
     ],
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java
index 58865fa..438df34 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ConfirmationDialog.java
@@ -29,7 +29,7 @@
   private Button okButton;
 
   public ConfirmationDialog(
-      final String dialogTitle, final SafeHtml message, final ConfirmationCallback callback) {
+      final String dialogTitle, SafeHtml message, ConfirmationCallback callback) {
     super(/* auto hide */ false, /* modal */ true);
     setGlassEnabled(true);
     setText(dialogTitle);
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 f077b20..5302808 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
@@ -40,7 +40,6 @@
 import static com.google.gerrit.common.PageLinks.SETTINGS_PROJECTS;
 import static com.google.gerrit.common.PageLinks.SETTINGS_SSHKEYS;
 import static com.google.gerrit.common.PageLinks.SETTINGS_WEBIDENT;
-import static com.google.gerrit.common.PageLinks.toChangeQuery;
 
 import com.google.gerrit.client.account.MyAgreementsScreen;
 import com.google.gerrit.client.account.MyContactInformationScreen;
@@ -77,6 +76,7 @@
 import com.google.gerrit.client.api.ExtensionSettingsScreen;
 import com.google.gerrit.client.change.ChangeScreen;
 import com.google.gerrit.client.change.FileTable;
+import com.google.gerrit.client.change.ProjectChangeId;
 import com.google.gerrit.client.changes.AccountDashboardScreen;
 import com.google.gerrit.client.changes.CustomDashboardScreen;
 import com.google.gerrit.client.changes.ProjectDashboardScreen;
@@ -93,6 +93,7 @@
 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.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Account;
@@ -108,53 +109,65 @@
 import com.google.gwtorm.client.KeyUtil;
 
 public class Dispatcher {
-  public static String toPatch(DiffObject diffBase, PatchSet.Id revision, String fileName) {
-    return toPatch("", diffBase, revision, fileName, null, 0);
+  public static String toPatch(
+      @Nullable Project.NameKey project,
+      DiffObject diffBase,
+      PatchSet.Id revision,
+      String fileName) {
+    return toPatch("", project, diffBase, revision, fileName, null, 0);
   }
 
   public static String toPatch(
-      DiffObject diffBase, PatchSet.Id revision, String fileName, DisplaySide side, int line) {
-    return toPatch("", diffBase, revision, fileName, side, line);
+      @Nullable Project.NameKey project,
+      DiffObject diffBase,
+      PatchSet.Id revision,
+      String fileName,
+      DisplaySide side,
+      int line) {
+    return toPatch("", project, diffBase, revision, fileName, side, line);
   }
 
-  public static String toSideBySide(DiffObject diffBase, Patch.Key id) {
-    return toPatch("sidebyside", diffBase, id);
+  public static String toSideBySide(
+      @Nullable Project.NameKey project,
+      DiffObject diffBase,
+      PatchSet.Id revision,
+      String fileName) {
+    return toPatch("sidebyside", project, diffBase, revision, fileName, null, 0);
   }
 
-  public static String toSideBySide(DiffObject diffBase, PatchSet.Id revision, String fileName) {
-    return toPatch("sidebyside", diffBase, revision, fileName, null, 0);
+  public static String toUnified(
+      @Nullable Project.NameKey project,
+      DiffObject diffBase,
+      PatchSet.Id revision,
+      String fileName) {
+    return toPatch("unified", project, diffBase, revision, fileName, null, 0);
   }
 
-  public static String toUnified(DiffObject diffBase, PatchSet.Id revision, String fileName) {
-    return toPatch("unified", diffBase, revision, fileName, null, 0);
+  public static String toPatch(
+      @Nullable Project.NameKey project, String type, DiffObject diffBase, Patch.Key id) {
+    return toPatch(type, project, diffBase, id.getParentKey(), id.get(), null, 0);
   }
 
-  public static String toUnified(DiffObject diffBase, Patch.Key id) {
-    return toPatch("unified", diffBase, id);
+  public static String toEditScreen(
+      @Nullable Project.NameKey project, PatchSet.Id revision, String fileName) {
+    return toEditScreen(project, revision, fileName, 0);
   }
 
-  public static String toPatch(String type, DiffObject diffBase, Patch.Key id) {
-    return toPatch(type, diffBase, id.getParentKey(), id.get(), null, 0);
-  }
-
-  public static String toEditScreen(PatchSet.Id revision, String fileName) {
-    return toEditScreen(revision, fileName, 0);
-  }
-
-  public static String toEditScreen(PatchSet.Id revision, String fileName, int line) {
-    return toPatch("edit", DiffObject.base(), revision, fileName, null, line);
+  public static String toEditScreen(
+      @Nullable Project.NameKey project, PatchSet.Id revision, String fileName, int line) {
+    return toPatch("edit", project, DiffObject.base(), revision, fileName, null, line);
   }
 
   private static String toPatch(
       String type,
+      @Nullable Project.NameKey project,
       DiffObject diffBase,
       PatchSet.Id revision,
       String fileName,
       DisplaySide side,
       int line) {
     Change.Id c = revision.getParentKey();
-    StringBuilder p = new StringBuilder();
-    p.append("/c/").append(c).append("/");
+    StringBuilder p = new StringBuilder(PageLinks.toChange(project, c));
     if (diffBase != null && diffBase.asString() != null) {
       p.append(diffBase.asString()).append("..");
     }
@@ -170,7 +183,7 @@
     return p.toString();
   }
 
-  public static String toGroup(final AccountGroup.Id id) {
+  public static String toGroup(AccountGroup.Id id) {
     return ADMIN_GROUPS + id.toString();
   }
 
@@ -293,7 +306,7 @@
     return r;
   }
 
-  private static void dashboard(final String token) {
+  private static void dashboard(String token) {
     String rest = skip(token);
     if (rest.matches("[0-9]+")) {
       Gerrit.display(token, new AccountDashboardScreen(Account.Id.parse(rest)));
@@ -319,7 +332,7 @@
     Gerrit.display(token, new NotFoundScreen());
   }
 
-  private static void projects(final String token) {
+  private static void projects(String token) {
     String rest = skip(token);
     int c = rest.indexOf(DASHBOARDS);
     if (0 <= c) {
@@ -343,7 +356,8 @@
               public void onFailure(Throwable caught) {
                 if ("default".equals(dashboardId) && RestApi.isNotFound(caught)) {
                   Gerrit.display(
-                      toChangeQuery(PageLinks.projectQuery(new Project.NameKey(project))));
+                      PageLinks.toChangeQuery(
+                          PageLinks.projectQuery(new Project.NameKey(project))));
                 } else {
                   super.onFailure(caught);
                 }
@@ -366,7 +380,7 @@
     Gerrit.display(token, new NotFoundScreen());
   }
 
-  private static void change(final String token) {
+  private static void change(String token) {
     String rest = skip(token);
     int c = rest.lastIndexOf(',');
     String panel = null;
@@ -380,15 +394,8 @@
       }
     }
 
-    Change.Id id;
-    int s = rest.indexOf('/');
-    if (0 <= s) {
-      id = Change.Id.parse(rest.substring(0, s));
-      rest = rest.substring(s + 1);
-    } else {
-      id = Change.Id.parse(rest);
-      rest = "";
-    }
+    ProjectChangeId id = ProjectChangeId.create(rest);
+    rest = rest.length() > id.identifierLength() ? rest.substring(id.identifierLength() + 1) : "";
 
     if (rest.isEmpty()) {
       FileTable.Mode mode = FileTable.Mode.REVIEW;
@@ -399,13 +406,14 @@
       Gerrit.display(
           token,
           panel == null
-              ? new ChangeScreen(id, DiffObject.base(), null, false, mode)
+              ? new ChangeScreen(
+                  id.getProject(), id.getChangeId(), DiffObject.base(), null, false, mode)
               : new NotFoundScreen());
       return;
     }
 
     String psIdStr;
-    s = rest.indexOf('/');
+    int s = rest.indexOf('/');
     if (0 <= s) {
       psIdStr = rest.substring(0, s);
       rest = rest.substring(s + 1);
@@ -418,13 +426,13 @@
     PatchSet.Id ps;
     int dotdot = psIdStr.indexOf("..");
     if (1 <= dotdot) {
-      base = DiffObject.parse(id, psIdStr.substring(0, dotdot));
+      base = DiffObject.parse(id.getChangeId(), psIdStr.substring(0, dotdot));
       if (base == null) {
         Gerrit.display(token, new NotFoundScreen());
       }
       psIdStr = psIdStr.substring(dotdot + 2);
     }
-    ps = toPsId(id, psIdStr);
+    ps = toPsId(id.getChangeId(), psIdStr);
 
     if (!rest.isEmpty()) {
       DisplaySide side = DisplaySide.B;
@@ -440,12 +448,18 @@
         rest = rest.substring(0, at);
       }
       Patch.Key p = new Patch.Key(ps, KeyUtil.decode(rest));
-      patch(token, base, p, side, line, panel);
+      patch(token, id.getProject(), base, p, side, line, panel);
     } else {
       if (panel == null) {
         Gerrit.display(
             token,
-            new ChangeScreen(id, base, String.valueOf(ps.get()), false, FileTable.Mode.REVIEW));
+            new ChangeScreen(
+                id.getProject(),
+                id.getChangeId(),
+                base,
+                String.valueOf(ps.get()),
+                false,
+                FileTable.Mode.REVIEW));
       } else {
         Gerrit.display(token, new NotFoundScreen());
       }
@@ -456,7 +470,7 @@
     return new PatchSet.Id(id, psIdStr.equals("edit") ? 0 : Integer.parseInt(psIdStr));
   }
 
-  private static void extension(final String token) {
+  private static void extension(String token) {
     ExtensionScreen view = new ExtensionScreen(skip(token));
     if (view.isFound()) {
       Gerrit.display(token, view);
@@ -466,7 +480,13 @@
   }
 
   private static void patch(
-      String token, DiffObject base, Patch.Key id, DisplaySide side, int line, String panelType) {
+      String token,
+      @Nullable Project.NameKey project,
+      DiffObject base,
+      Patch.Key id,
+      DisplaySide side,
+      int line,
+      String panelType) {
     String panel = panelType;
     if (panel == null) {
       int c = token.lastIndexOf(',');
@@ -475,17 +495,17 @@
 
     if ("".equals(panel) || /* DEPRECATED URL */ "cm".equals(panel)) {
       if (preferUnified()) {
-        unified(token, base, id, side, line);
+        unified(token, project, base, id, side, line);
       } else {
-        codemirror(token, base, id, side, line);
+        codemirror(token, base, project, id, side, line);
       }
     } else if ("sidebyside".equals(panel)) {
-      codemirror(token, base, id, side, line);
+      codemirror(token, base, project, id, side, line);
     } else if ("unified".equals(panel)) {
-      unified(token, base, id, side, line);
+      unified(token, project, base, id, side, line);
     } else if ("edit".equals(panel)) {
       if (!Patch.isMagic(id.get()) || Patch.COMMIT_MSG.equals(id.get())) {
-        codemirrorForEdit(token, id, line);
+        codemirrorForEdit(token, project, id, line);
       } else {
         Gerrit.display(token, new NotFoundScreen());
       }
@@ -501,6 +521,7 @@
 
   private static void unified(
       final String token,
+      final Project.NameKey project,
       final DiffObject base,
       final Patch.Key id,
       final DisplaySide side,
@@ -511,7 +532,8 @@
           public void onSuccess() {
             Gerrit.display(
                 token,
-                new Unified(base, DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
+                new Unified(
+                    project, base, DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
           }
         });
   }
@@ -519,6 +541,7 @@
   private static void codemirror(
       final String token,
       final DiffObject base,
+      @Nullable final Project.NameKey project,
       final Patch.Key id,
       final DisplaySide side,
       final int line) {
@@ -528,17 +551,22 @@
           public void onSuccess() {
             Gerrit.display(
                 token,
-                new SideBySide(base, DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
+                new SideBySide(
+                    project, base, DiffObject.patchSet(id.getParentKey()), id.get(), side, line));
           }
         });
   }
 
-  private static void codemirrorForEdit(final String token, final Patch.Key id, final int line) {
+  private static void codemirrorForEdit(
+      final String token,
+      @Nullable final Project.NameKey project,
+      final Patch.Key id,
+      final int line) {
     GWT.runAsync(
         new AsyncSplit(token) {
           @Override
           public void onSuccess() {
-            Gerrit.display(token, new EditScreen(id, line));
+            Gerrit.display(token, new EditScreen(project, id, line));
           }
         });
   }
@@ -839,7 +867,7 @@
     }
   }
 
-  private static void docSearch(final String token) {
+  private static void docSearch(String token) {
     GWT.runAsync(
         new AsyncSplit(token) {
           @Override
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 8e12575..c116d76 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
@@ -86,19 +86,19 @@
   }
 
   /** Create a dialog box to show a single message string. */
-  public ErrorDialog(final String message) {
+  public ErrorDialog(String message) {
     this();
     body.add(createErrorMsgLabel(message));
   }
 
   /** Create a dialog box to show a single message string. */
-  public ErrorDialog(final SafeHtml message) {
+  public ErrorDialog(SafeHtml message) {
     this();
     body.add(message.toBlockWidget());
   }
 
   /** Create a dialog box to nicely format an exception. */
-  public ErrorDialog(final Throwable what) {
+  public ErrorDialog(Throwable what) {
     this();
 
     String hdr;
@@ -155,7 +155,7 @@
     return m;
   }
 
-  public ErrorDialog setText(final String t) {
+  public ErrorDialog setText(String t) {
     text.setText(t);
     return this;
   }
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 751302e..e02c4e0 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
@@ -170,7 +170,7 @@
    *
    * @param token location to parse, load, and render.
    */
-  public static void display(final String token) {
+  public static void display(String token) {
     if (body.getView() == null || !body.getView().displayToken(token)) {
       dispatcher.display(token);
       updateUiLink(token);
@@ -191,7 +191,7 @@
    * @param token location that refers to {@code view}.
    * @param view the view to load.
    */
-  public static void display(final String token, final Screen view) {
+  public static void display(String token, Screen view) {
     if (view.isRequiresSignIn() && !isSignedIn()) {
       doSignIn(token);
     } else {
@@ -217,7 +217,7 @@
    *
    * @param token new location that is already visible.
    */
-  public static void updateImpl(final String token) {
+  public static void updateImpl(String token) {
     History.newItem(token, false);
     dispatchHistoryHooks(token);
   }
@@ -226,7 +226,7 @@
     searchPanel.setText(query);
   }
 
-  public static void setWindowTitle(final Screen screen, final String text) {
+  public static void setWindowTitle(Screen screen, String text) {
     if (screen == body.getView()) {
       if (text == null || text.length() == 0) {
         Window.setTitle(M.windowTitle1(myHost));
@@ -428,7 +428,7 @@
           }
 
           @Override
-          public String decode(final String e) {
+          public String decode(String e) {
             return URL.decodeQueryString(e);
           }
 
@@ -476,7 +476,7 @@
         cbg.addFinal(
             new GerritCallback<HostPageData>() {
               @Override
-              public void onSuccess(final HostPageData result) {
+              public void onSuccess(HostPageData result) {
                 Document.get().getElementById("gerrit_hostpagedata").removeFromParent();
                 myTheme = result.theme;
                 isNoteDbEnabled = result.isNoteDbEnabled;
@@ -957,7 +957,7 @@
     return docSearch;
   }
 
-  private static void getDocIndex(final AsyncCallback<DocInfo> cb) {
+  private static void getDocIndex(AsyncCallback<DocInfo> cb) {
     RequestBuilder req = new RequestBuilder(RequestBuilder.HEAD, GWT.getHostPageBaseURL() + INDEX);
     req.setCallback(
         new RequestCallback() {
@@ -1031,22 +1031,21 @@
     menuRight.add(fp);
   }
 
-  private static Anchor anchor(final String text, final String to) {
+  private static Anchor anchor(String text, String to) {
     final Anchor a = new Anchor(text, to);
     a.setStyleName(RESOURCES.css().menuItem());
     Roles.getMenuitemRole().set(a.getElement());
     return a;
   }
 
-  private static LinkMenuItem addLink(
-      final LinkMenuBar m, final String text, final String historyToken) {
+  private static LinkMenuItem addLink(final LinkMenuBar m, String text, String historyToken) {
     LinkMenuItem i = new LinkMenuItem(text, historyToken);
     m.addItem(i);
     return i;
   }
 
   private static void insertLink(
-      final LinkMenuBar m, final String text, final String historyToken, final int beforeIndex) {
+      final LinkMenuBar m, String text, String historyToken, int beforeIndex) {
     m.insertItem(new LinkMenuItem(text, historyToken), beforeIndex);
   }
 
@@ -1090,7 +1089,7 @@
     return i;
   }
 
-  private static void addDocLink(final LinkMenuBar m, final String text, final String href) {
+  private static void addDocLink(LinkMenuBar m, String text, String href) {
     final Anchor atag = anchor(text, docUrl + href);
     atag.setTarget("_blank");
     m.add(atag);
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 b44cd1c..eae3431 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
@@ -91,8 +91,6 @@
 
   String menuMyChanges();
 
-  String menuMyDrafts();
-
   String menuMyWatchedChanges();
 
   String menuMyStarredChanges();
@@ -175,8 +173,6 @@
 
   String jumpMine();
 
-  String jumpMineDrafts();
-
   String jumpMineWatched();
 
   String jumpMineStarred();
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 9aa4388..2819d22 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
@@ -29,7 +29,7 @@
 tagDeletionDialogTitle = Tag Deletion
 tagDeletionConfirmationMessage = Do you really want to delete the following tags?
 
-newUi = New UI
+newUi = Switch to New UI
 
 notSignedInTitle = Code Review - Session Expired
 notSignedInBody = <b>Session Expired</b>\
@@ -53,7 +53,6 @@
 
 menuMine = My
 menuMyChanges = Changes
-menuMyDrafts = Drafts
 menuMyStarredChanges = Starred Changes
 menuMyWatchedChanges = Watched Changes
 menuMyDraftComments = Draft Comments
@@ -105,7 +104,6 @@
 jumpAllAbandoned = Go to all abandoned changes
 jumpMine = Go to my dashboard
 jumpMineWatched = Go to watched changes
-jumpMineDrafts = Go to drafts
 jumpMineStarred = Go to starred changes
 jumpMineDraftComments = Go to draft comments
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java
index b8195805..a4879ca 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/JumpKeys.java
@@ -37,27 +37,27 @@
     }
   }
 
-  static void register(final Widget body) {
+  static void register(Widget body) {
     final KeyCommandSet jumps = new KeyCommandSet();
 
     jumps.add(
         new KeyCommand(0, 'o', Gerrit.C.jumpAllOpen()) {
           @Override
-          public void onKeyPress(final KeyPressEvent event) {
+          public void onKeyPress(KeyPressEvent event) {
             Gerrit.display(PageLinks.toChangeQuery("status:open"));
           }
         });
     jumps.add(
         new KeyCommand(0, 'm', Gerrit.C.jumpAllMerged()) {
           @Override
-          public void onKeyPress(final KeyPressEvent event) {
+          public void onKeyPress(KeyPressEvent event) {
             Gerrit.display(PageLinks.toChangeQuery("status:merged"));
           }
         });
     jumps.add(
         new KeyCommand(0, 'a', Gerrit.C.jumpAllAbandoned()) {
           @Override
-          public void onKeyPress(final KeyPressEvent event) {
+          public void onKeyPress(KeyPressEvent event) {
             Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
           }
         });
@@ -66,35 +66,28 @@
       jumps.add(
           new KeyCommand(0, 'i', Gerrit.C.jumpMine()) {
             @Override
-            public void onKeyPress(final KeyPressEvent event) {
+            public void onKeyPress(KeyPressEvent event) {
               Gerrit.display(PageLinks.MINE);
             }
           });
       jumps.add(
-          new KeyCommand(0, 'd', Gerrit.C.jumpMineDrafts()) {
-            @Override
-            public void onKeyPress(final KeyPressEvent event) {
-              Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
-            }
-          });
-      jumps.add(
           new KeyCommand(0, 'c', Gerrit.C.jumpMineDraftComments()) {
             @Override
-            public void onKeyPress(final KeyPressEvent event) {
+            public void onKeyPress(KeyPressEvent event) {
               Gerrit.display(PageLinks.toChangeQuery("has:draft"));
             }
           });
       jumps.add(
           new KeyCommand(0, 'w', Gerrit.C.jumpMineWatched()) {
             @Override
-            public void onKeyPress(final KeyPressEvent event) {
+            public void onKeyPress(KeyPressEvent event) {
               Gerrit.display(PageLinks.toChangeQuery("is:watched status:open"));
             }
           });
       jumps.add(
           new KeyCommand(0, 's', Gerrit.C.jumpMineStarred()) {
             @Override
-            public void onKeyPress(final KeyPressEvent event) {
+            public void onKeyPress(KeyPressEvent event) {
               Gerrit.display(PageLinks.toChangeQuery("is:starred"));
             }
           });
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RpcStatus.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RpcStatus.java
index cd715c6..4153439 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RpcStatus.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RpcStatus.java
@@ -28,7 +28,7 @@
   private static int hideDepth;
 
   /** Execute code, hiding the RPCs they execute from being shown visually. */
-  public static void hide(final Runnable run) {
+  public static void hide(Runnable run) {
     try {
       hideDepth++;
       run.run();
@@ -49,7 +49,7 @@
   }
 
   @Override
-  public void onRpcStart(final RpcStartEvent event) {
+  public void onRpcStart(RpcStartEvent event) {
     onRpcStart();
   }
 
@@ -62,7 +62,7 @@
   }
 
   @Override
-  public void onRpcComplete(final RpcCompleteEvent event) {
+  public void onRpcComplete(RpcCompleteEvent event) {
     onRpcComplete();
   }
 
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 37c6a0b..406ab4e 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
@@ -48,7 +48,7 @@
     searchBox.addKeyPressHandler(
         new KeyPressHandler() {
           @Override
-          public void onKeyPress(final KeyPressEvent event) {
+          public void onKeyPress(KeyPressEvent event) {
             if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
               if (!suggestionDisplay.isSuggestionSelected) {
                 doSearch();
@@ -92,7 +92,7 @@
     body.add(searchButton);
   }
 
-  void setText(final String query) {
+  void setText(String query) {
     searchBox.setText(query);
   }
 
@@ -105,7 +105,7 @@
               this,
               new KeyCommand(0, '/', Gerrit.C.keySearch()) {
                 @Override
-                public void onKeyPress(final KeyPressEvent event) {
+                public void onKeyPress(KeyPressEvent event) {
                   event.preventDefault();
                   searchBox.setFocus(true);
                   searchBox.selectAll();
@@ -136,7 +136,8 @@
     } else {
       // changes
       if (query.matches("^[1-9][0-9]*$")) {
-        Gerrit.display(PageLinks.toChange(Change.Id.parse(query)));
+        // Query is a change number. Project can't be supplied.
+        Gerrit.display(PageLinks.toChange(null, Change.Id.parse(query)));
       } else {
         Gerrit.display(PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
       }
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 20bc2746..e74ed71 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
@@ -44,13 +44,12 @@
                   "cc:"),
               new AccountSuggestOracle() {
                 @Override
-                public void onRequestSuggestions(final Request request, final Callback done) {
+                public void onRequestSuggestions(Request request, Callback done) {
                   super.onRequestSuggestions(
                       request,
                       new Callback() {
                         @Override
-                        public void onSuggestionsReady(
-                            final Request request, final Response response) {
+                        public void onSuggestionsReady(final Request request, Response response) {
                           if ("self".startsWith(request.getQuery())) {
                             final ArrayList<SuggestOracle.Suggestion> r =
                                 new ArrayList<>(response.getSuggestions().size() + 1);
@@ -90,6 +89,7 @@
     suggestions.add("ownerin:");
     suggestions.add("author:");
     suggestions.add("committer:");
+    suggestions.add("assignee:");
 
     suggestions.add("reviewer:");
     suggestions.add("reviewer:self");
@@ -130,11 +130,15 @@
     suggestions.add("is:reviewer");
     suggestions.add("is:open");
     suggestions.add("is:pending");
-    suggestions.add("is:draft");
+    suggestions.add("is:private");
     suggestions.add("is:closed");
     suggestions.add("is:merged");
     suggestions.add("is:abandoned");
     suggestions.add("is:mergeable");
+    suggestions.add("is:ignored");
+    suggestions.add("is:wip");
+    suggestions.add("is:assigned");
+    suggestions.add("is:submittable");
 
     suggestions.add("status:");
     suggestions.add("status:open");
@@ -143,7 +147,6 @@
     suggestions.add("status:closed");
     suggestions.add("status:merged");
     suggestions.add("status:abandoned");
-    suggestions.add("status:draft");
 
     suggestions.add("added:");
     suggestions.add("deleted:");
@@ -152,6 +155,8 @@
 
     suggestions.add("unresolved:");
 
+    suggestions.add("revertof:");
+
     if (Gerrit.isNoteDbEnabled()) {
       suggestions.add("cc:");
       suggestions.add("hashtag:");
@@ -189,7 +194,7 @@
       return;
     }
 
-    for (final ParamSuggester ps : paramSuggester) {
+    for (ParamSuggester ps : paramSuggester) {
       if (ps.applicable(lastWord)) {
         ps.suggest(lastWord, request, done);
         return;
@@ -208,7 +213,7 @@
     done.onSuggestionsReady(request, new Response(r));
   }
 
-  private String getLastWord(final String query) {
+  private String getLastWord(String query) {
     final int lastSpace = query.lastIndexOf(' ');
     if (lastSpace == query.length() - 1) {
       return null;
@@ -220,7 +225,7 @@
   }
 
   @Override
-  protected String getQueryPattern(final String query) {
+  protected String getQueryPattern(String query) {
     return super.getQueryPattern(getLastWord(query));
   }
 
@@ -255,18 +260,18 @@
     private final List<String> operators;
     private final SuggestOracle parameterSuggestionOracle;
 
-    ParamSuggester(final List<String> operators, final SuggestOracle parameterSuggestionOracle) {
+    ParamSuggester(List<String> operators, SuggestOracle parameterSuggestionOracle) {
       this.operators = operators;
       this.parameterSuggestionOracle = parameterSuggestionOracle;
     }
 
-    boolean applicable(final String query) {
+    boolean applicable(String query) {
       final String operator = getApplicableOperator(query, operators);
       return operator != null && query.length() > operator.length();
     }
 
-    private String getApplicableOperator(final String lastWord, final List<String> operators) {
-      for (final String operator : operators) {
+    private String getApplicableOperator(String lastWord, List<String> operators) {
+      for (String operator : operators) {
         if (lastWord.startsWith(operator)) {
           return operator;
         }
@@ -274,17 +279,17 @@
       return null;
     }
 
-    void suggest(final String lastWord, final Request request, final Callback done) {
+    void suggest(String lastWord, Request request, Callback done) {
       final String operator = getApplicableOperator(lastWord, operators);
       parameterSuggestionOracle.requestSuggestions(
           new Request(lastWord.substring(operator.length()), request.getLimit()),
           new Callback() {
             @Override
-            public void onSuggestionsReady(final Request req, final Response response) {
+            public void onSuggestionsReady(Request req, Response response) {
               final String query = request.getQuery();
               final List<SearchSuggestOracle.Suggestion> r =
                   new ArrayList<>(response.getSuggestions().size());
-              for (final SearchSuggestOracle.Suggestion s : response.getSuggestions()) {
+              for (SearchSuggestOracle.Suggestion s : response.getSuggestions()) {
                 r.add(
                     new SearchSuggestion(
                         s.getDisplayString(),
@@ -295,7 +300,7 @@
               done.onSuggestionsReady(request, new Response(r));
             }
 
-            private String quoteIfNeeded(final String s) {
+            private String quoteIfNeeded(String s) {
               if (!s.matches("^\\S*$")) {
                 return "\"" + s + "\"";
               }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java
index 1a1f7bd..f771fee 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/StringListPanel.java
@@ -189,7 +189,7 @@
       return v;
     }
 
-    private void populate(final int row, List<String> values) {
+    private void populate(int row, List<String> values) {
       FlexCellFormatter fmt = table.getFlexCellFormatter();
       fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().iconCell());
       fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().leftMostCell());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
index 40116af..cb529f4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
@@ -52,8 +52,9 @@
       userEmail.setText(account.email());
     }
     if (showSettingsLink) {
-      if (Gerrit.info().auth().switchAccountUrl() != null) {
-        switchAccount.setHref(Gerrit.info().auth().switchAccountUrl());
+      String switchAccountUrl = Gerrit.info().auth().switchAccountUrl();
+      if (switchAccountUrl != null) {
+        switchAccount.setHref(switchAccountUrl.replace("${path}", "/"));
       } else if (Gerrit.info().auth().isDev() || Gerrit.info().auth().isOpenId()) {
         switchAccount.setHref(Gerrit.selfRedirect("/login"));
       } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java
index 39a52e3..a0060d5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/access/AccessMap.java
@@ -31,7 +31,7 @@
     api.get(NativeMap.copyKeysIntoChildren(callback));
   }
 
-  public static void get(final Project.NameKey project, final AsyncCallback<ProjectAccessInfo> cb) {
+  public static void get(Project.NameKey project, AsyncCallback<ProjectAccessInfo> cb) {
     get(
         Collections.singleton(project),
         new AsyncCallback<AccessMap>() {
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 87694f9..3e21619 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
@@ -67,6 +67,10 @@
 
   String signedOffBy();
 
+  String publishCommentsOnPush();
+
+  String workInProgressByDefault();
+
   String myMenu();
 
   String myMenuInfo();
@@ -239,6 +243,10 @@
 
   String errorDialogTitleRegisterNewEmail();
 
+  String emailFilterHelpTitle();
+
+  String emailFilterHelp();
+
   String newAgreement();
 
   String agreementName();
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 481a9a7..59b8b3d 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
@@ -38,6 +38,8 @@
 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
+publishCommentsOnPush = Publish Draft Comments When a Change Is Updated by Push
+workInProgressByDefault = Set all new changes work-in-progress by default
 myMenu = My Menu
 myMenuInfo = \
   Menu items for the 'My' top level menu. \
@@ -158,7 +160,74 @@
   <p>A confirmation link will be sent by email to this address.</p>\
   <p>You must click on the link to complete the registration and make the address available for selection.</p>
 errorDialogTitleRegisterNewEmail = Email Registration Failed
-
+emailFilterHelpTitle = Mail Filters
+emailFilterHelp = \
+  <p>\
+    Gerrit emails include metadata about the change to support \
+    writing mail filters.\
+  </p>\
+  <p>\
+    Here are some example Gmail queries that can be used for filters or \
+    for searching through archived messages. View the \
+    <a href="https://gerrit-review.googlesource.com/Documentation/user-notify.html"\
+        target="_blank" rel="nofollow">Gerrit documentation</a> for \
+    the complete set of footers.\
+  </p>\
+  <table>\
+    <tbody>\
+      <tr><th>Name</th><th>Query</th></tr>\
+      <tr>\
+        <td>Changes requesting my review</td>\
+        <td>\
+          <code>\
+            "Gerrit-Reviewer: <em>Your Name</em>\
+            &lt;<em>your.email@example.com</em>&gt;"\
+          </code>\
+        </td>\
+      </tr>\
+      <tr>\
+        <td>Changes from a specific owner</td>\
+        <td>\
+          <code>\
+            "Gerrit-Owner: <em>Owner name</em>\
+            &lt;<em>owner.email@example.com</em>&gt;"\
+          </code>\
+        </td>\
+      </tr>\
+      <tr>\
+        <td>Changes targeting a specific branch</td>\
+        <td>\
+          <code>\
+            "Gerrit-Branch: <em>branch-name</em>"\
+          </code>\
+        </td>\
+      </tr>\
+      <tr>\
+        <td>Changes in a specific project</td>\
+        <td>\
+          <code>\
+            "Gerrit-Project: <em>project-name</em>"\
+          </code>\
+        </td>\
+      </tr>\
+      <tr>\
+        <td>Messages related to a specific Change ID</td>\
+        <td>\
+          <code>\
+            "Gerrit-Change-Id: <em>Change ID</em>"\
+          </code>\
+        </td>\
+      </tr>\
+      <tr>\
+        <td>Messages related to a specific change number</td>\
+        <td>\
+          <code>\
+            "Gerrit-Change-Number: <em>change number</em>"\
+          </code>\
+        </td>\
+      </tr>\
+    </tbody>\
+  </table>
 
 newAgreement = New Contributor Agreement
 agreementName = Name
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 da0357f..a537063 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
@@ -21,6 +21,7 @@
 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.ComplexDisclosurePanel;
 import com.google.gerrit.client.ui.OnEditEnabler;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.errors.EmailException;
@@ -91,7 +92,7 @@
     registerNewEmail.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doRegisterNewEmail();
           }
         });
@@ -148,15 +149,20 @@
     save.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doSave();
           }
         });
 
+    final ComplexDisclosurePanel mailFilterHelp =
+        new ComplexDisclosurePanel(Util.C.emailFilterHelpTitle(), false);
+    mailFilterHelp.setContent(new HTML(Util.C.emailFilterHelp()));
+    body.add(mailFilterHelp);
+
     emailPick.addChangeHandler(
         new ChangeHandler() {
           @Override
-          public void onChange(final ChangeEvent event) {
+          public void onChange(ChangeEvent event) {
             final int idx = emailPick.getSelectedIndex();
             final String v = 0 <= idx ? emailPick.getValue(idx) : null;
             if (Util.C.buttonOpenRegisterNewEmail().equals(v)) {
@@ -249,7 +255,7 @@
 
   void display() {}
 
-  protected void row(final Grid info, final int row, final String name, final Widget field) {
+  protected void row(Grid info, int row, String name, Widget field) {
     info.setText(row, labelIdx, name);
     info.setWidget(row, fieldIdx, field);
     info.getCellFormatter().addStyleName(row, 0, Gerrit.RESOURCES.css().header());
@@ -279,7 +285,7 @@
     form.addSubmitHandler(
         new FormPanel.SubmitHandler() {
           @Override
-          public void onSubmit(final SubmitEvent event) {
+          public void onSubmit(SubmitEvent event) {
             event.cancel();
             final String addr = inEmail.getText().trim();
             if (!addr.contains("@")) {
@@ -310,7 +316,7 @@
                   }
 
                   @Override
-                  public void onFailure(final Throwable caught) {
+                  public void onFailure(Throwable caught) {
                     inEmail.setEnabled(true);
                     register.setEnabled(true);
                     if (caught.getMessage().startsWith(EmailException.MESSAGE)) {
@@ -331,7 +337,7 @@
     register.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             form.submit();
           }
         });
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
index dfbd5c7..5c6d40f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyIdentitiesScreen.java
@@ -49,7 +49,7 @@
     deleteIdentity.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             identites.deleteChecked();
           }
         });
@@ -60,7 +60,7 @@
       linkIdentity.addClickHandler(
           new ClickHandler() {
             @Override
-            public void onClick(final ClickEvent event) {
+            public void onClick(ClickEvent event) {
               Location.assign(Gerrit.loginRedirect(History.getToken()) + "?link");
             }
           });
@@ -167,7 +167,7 @@
       deleteIdentity.setEnabled(on);
     }
 
-    void display(final JsArray<ExternalIdInfo> results) {
+    void display(JsArray<ExternalIdInfo> results) {
       List<ExternalIdInfo> idList = Natives.asList(results);
       Collections.sort(idList);
 
@@ -175,13 +175,13 @@
         table.removeRow(table.getRowCount() - 1);
       }
 
-      for (final ExternalIdInfo k : idList) {
+      for (ExternalIdInfo k : idList) {
         addOneId(k);
       }
       updateDeleteButton();
     }
 
-    void addOneId(final ExternalIdInfo k) {
+    void addOneId(ExternalIdInfo k) {
       if (k.isUsername()) {
         // Don't display the username as an identity here.
         return;
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
index 5836763..173dba6 100644
--- 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
@@ -111,7 +111,7 @@
             });
   }
 
-  private void display(final GeneralPreferences prefs) {
+  private void display(GeneralPreferences prefs) {
     AccountApi.self()
         .view("oauthtoken")
         .get(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
index e1d9ef0..5dd7530 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPasswordScreen.java
@@ -104,7 +104,7 @@
           }
 
           @Override
-          public void onFailure(final Throwable caught) {
+          public void onFailure(Throwable caught) {
             if (RestApi.isNotFound(caught)) {
               Gerrit.getUserAccount().username(null);
               display();
@@ -121,7 +121,7 @@
     enableUI(true);
   }
 
-  private void row(final Grid info, final int row, final String name, final Widget field) {
+  private void row(Grid info, int row, String name, Widget field) {
     final CellFormatter fmt = info.getCellFormatter();
     if (LocaleInfo.getCurrentLocale().isRTL()) {
       info.setText(row, 1, name);
@@ -146,7 +146,7 @@
             }
 
             @Override
-            public void onFailure(final Throwable caught) {
+            public void onFailure(Throwable caught) {
               enableUI(true);
             }
           });
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 2edc137..afb8718 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
@@ -55,6 +55,8 @@
   private CheckBox legacycidInChangeTable;
   private CheckBox muteCommonPathPrefixes;
   private CheckBox signedOffBy;
+  private CheckBox publishCommentsOnPush;
+  private CheckBox workInProgressByDefault;
   private ListBox maximumPageSize;
   private ListBox dateFormat;
   private ListBox timeFormat;
@@ -73,7 +75,7 @@
     showSiteHeader = new CheckBox(Util.C.showSiteHeader());
     useFlashClipboard = new CheckBox(Util.C.useFlashClipboard());
     maximumPageSize = new ListBox();
-    for (final int v : PAGESIZE_CHOICES) {
+    for (int v : PAGESIZE_CHOICES) {
       maximumPageSize.addItem(Util.M.rowsPerPage(v), String.valueOf(v));
     }
 
@@ -161,9 +163,11 @@
     legacycidInChangeTable = new CheckBox(Util.C.showLegacycidInChangeTable());
     muteCommonPathPrefixes = new CheckBox(Util.C.muteCommonPathPrefixes());
     signedOffBy = new CheckBox(Util.C.signedOffBy());
+    publishCommentsOnPush = new CheckBox(Util.C.publishCommentsOnPush());
+    workInProgressByDefault = new CheckBox(Util.C.workInProgressByDefault());
 
     boolean flashClippy = !UserAgent.hasJavaScriptClipboard() && UserAgent.Flash.isInstalled();
-    final Grid formGrid = new Grid(14 + (flashClippy ? 1 : 0), 2);
+    final Grid formGrid = new Grid(16 + (flashClippy ? 1 : 0), 2);
 
     int row = 0;
 
@@ -223,6 +227,14 @@
     formGrid.setWidget(row, fieldIdx, signedOffBy);
     row++;
 
+    formGrid.setText(row, labelIdx, "");
+    formGrid.setWidget(row, fieldIdx, publishCommentsOnPush);
+    row++;
+
+    formGrid.setText(row, labelIdx, "");
+    formGrid.setWidget(row, fieldIdx, workInProgressByDefault);
+    row++;
+
     if (flashClippy) {
       formGrid.setText(row, labelIdx, "");
       formGrid.setWidget(row, fieldIdx, useFlashClipboard);
@@ -235,7 +247,7 @@
     save.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doSave();
           }
         });
@@ -257,6 +269,8 @@
     e.listenTo(legacycidInChangeTable);
     e.listenTo(muteCommonPathPrefixes);
     e.listenTo(signedOffBy);
+    e.listenTo(publishCommentsOnPush);
+    e.listenTo(workInProgressByDefault);
     e.listenTo(diffView);
     e.listenTo(reviewCategoryStrategy);
     e.listenTo(emailStrategy);
@@ -283,7 +297,7 @@
             });
   }
 
-  private void enable(final boolean on) {
+  private void enable(boolean on) {
     showSiteHeader.setEnabled(on);
     useFlashClipboard.setEnabled(on);
     maximumPageSize.setEnabled(on);
@@ -295,6 +309,8 @@
     legacycidInChangeTable.setEnabled(on);
     muteCommonPathPrefixes.setEnabled(on);
     signedOffBy.setEnabled(on);
+    publishCommentsOnPush.setEnabled(on);
+    workInProgressByDefault.setEnabled(on);
     reviewCategoryStrategy.setEnabled(on);
     diffView.setEnabled(on);
     emailStrategy.setEnabled(on);
@@ -320,6 +336,8 @@
     legacycidInChangeTable.setValue(p.legacycidInChangeTable());
     muteCommonPathPrefixes.setValue(p.muteCommonPathPrefixes());
     signedOffBy.setValue(p.signedOffBy());
+    publishCommentsOnPush.setValue(p.publishCommentsOnPush());
+    workInProgressByDefault.setValue(p.workInProgressByDefault());
     setListBox(
         reviewCategoryStrategy,
         GeneralPreferencesInfo.ReviewCategoryStrategy.NONE,
@@ -342,19 +360,18 @@
     myMenus.display(values);
   }
 
-  private void setListBox(final ListBox f, final int defaultValue, final int currentValue) {
+  private void setListBox(ListBox f, int defaultValue, int currentValue) {
     setListBox(f, String.valueOf(defaultValue), String.valueOf(currentValue));
   }
 
-  private <T extends Enum<?>> void setListBox(
-      final ListBox f, final T defaultValue, final T currentValue) {
+  private <T extends Enum<?>> void setListBox(final ListBox f, T defaultValue, T currentValue) {
     setListBox(
         f,
         defaultValue != null ? defaultValue.name() : "",
         currentValue != null ? currentValue.name() : "");
   }
 
-  private void setListBox(final ListBox f, final String defaultValue, final String currentValue) {
+  private void setListBox(ListBox f, String defaultValue, String currentValue) {
     final int n = f.getItemCount();
     for (int i = 0; i < n; i++) {
       if (f.getValue(i).equals(currentValue)) {
@@ -367,7 +384,7 @@
     }
   }
 
-  private int getListBox(final ListBox f, final int defaultValue) {
+  private int getListBox(ListBox f, int defaultValue) {
     final int idx = f.getSelectedIndex();
     if (0 <= idx) {
       return Short.parseShort(f.getValue(idx));
@@ -375,7 +392,7 @@
     return defaultValue;
   }
 
-  private <T extends Enum<?>> T getListBox(final ListBox f, final T defaultValue, T[] all) {
+  private <T extends Enum<?>> T getListBox(ListBox f, T defaultValue, T[] all) {
     final int idx = f.getSelectedIndex();
     if (0 <= idx) {
       String v = f.getValue(idx);
@@ -412,6 +429,8 @@
     p.legacycidInChangeTable(legacycidInChangeTable.getValue());
     p.muteCommonPathPrefixes(muteCommonPathPrefixes.getValue());
     p.signedOffBy(signedOffBy.getValue());
+    p.publishCommentsOnPush(publishCommentsOnPush.getValue());
+    p.workInProgressByDefault(workInProgressByDefault.getValue());
     p.reviewCategoryStrategy(
         getListBox(
             reviewCategoryStrategy, ReviewCategoryStrategy.NONE, ReviewCategoryStrategy.values()));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java
index 9d67663..177fc09 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyProfileScreen.java
@@ -91,7 +91,7 @@
     display();
   }
 
-  private void infoRow(final int row, final String name) {
+  private void infoRow(int row, String name) {
     info.setText(row, labelIdx, name);
     info.getCellFormatter().addStyleName(row, 0, Gerrit.RESOURCES.css().header());
   }
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 d3ac463..c99cd1a 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
@@ -129,7 +129,7 @@
     addNew.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doAddNew();
           }
         });
@@ -138,7 +138,7 @@
     browse.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             int top = grid.getAbsoluteTop() - 50; // under page header
             // Try to place it to the right of everything else, but not
             // right justified
@@ -158,7 +158,7 @@
     delSel.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             watchesTab.deleteChecked();
           }
         });
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 5e45b68..0a61b2d 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
@@ -97,7 +97,7 @@
     return infos;
   }
 
-  public void insertWatch(final ProjectWatchInfo k) {
+  public void insertWatch(ProjectWatchInfo k) {
     final String newName = k.project();
     int row = 1;
     for (; row < table.getRowCount(); row++) {
@@ -112,7 +112,7 @@
     populate(row, k);
   }
 
-  public void display(final JsArray<ProjectWatchInfo> result) {
+  public void display(JsArray<ProjectWatchInfo> result) {
     while (2 < table.getRowCount()) {
       table.removeRow(table.getRowCount() - 1);
     }
@@ -125,7 +125,7 @@
     }
   }
 
-  protected void populate(final int row, final ProjectWatchInfo info) {
+  protected void populate(int row, ProjectWatchInfo info) {
     final FlowPanel fp = new FlowPanel();
     fp.add(new ProjectLink(info.project(), new Project.NameKey(info.project())));
     if (info.filter() != null) {
@@ -156,13 +156,13 @@
   }
 
   protected void addNotifyButton(
-      final ProjectWatchInfo.Type type, final ProjectWatchInfo info, final int row, final int col) {
+      final ProjectWatchInfo.Type type, ProjectWatchInfo info, int row, int col) {
     final CheckBox cbox = new CheckBox();
 
     cbox.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             final Boolean oldVal = info.notify(type);
             info.notify(type, cbox.getValue());
             cbox.setEnabled(false);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
index afba2e2..7c90884 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/NewAgreementScreen.java
@@ -66,7 +66,7 @@
     this(null);
   }
 
-  public NewAgreementScreen(final String token) {
+  public NewAgreementScreen(String token) {
     nextToken = token != null ? token : PageLinks.SETTINGS_AGREEMENTS;
   }
 
@@ -122,7 +122,7 @@
     submit.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doSign();
           }
         });
@@ -156,7 +156,7 @@
     }
     radios.add(hdr);
 
-    for (final AgreementInfo cla : available) {
+    for (AgreementInfo cla : available) {
       final RadioButton r = new RadioButton("cla_id", cla.name());
       r.addStyleName(Gerrit.RESOURCES.css().contributorAgreementButton());
       radios.add(r);
@@ -170,7 +170,7 @@
         r.addClickHandler(
             new ClickHandler() {
               @Override
-              public void onClick(final ClickEvent event) {
+              public void onClick(ClickEvent event) {
                 showCLA(cla);
               }
             });
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
index d3d217c..29de14a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/RegisterScreen.java
@@ -31,7 +31,7 @@
 public class RegisterScreen extends AccountScreen {
   private final String nextToken;
 
-  public RegisterScreen(final String next) {
+  public RegisterScreen(String next) {
     nextToken = next;
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshHostKeyPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshHostKeyPanel.java
index 70e3911..2dfc2ed 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshHostKeyPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshHostKeyPanel.java
@@ -24,7 +24,7 @@
 import com.google.gwtexpui.clippy.client.CopyableLabel;
 
 class SshHostKeyPanel extends Composite {
-  SshHostKeyPanel(final SshHostKey info) {
+  SshHostKeyPanel(SshHostKey info) {
     final FlowPanel body = new FlowPanel();
     body.setStyleName(Gerrit.RESOURCES.css().sshHostKeyPanel());
     body.add(new SmallHeading(Util.C.sshHostKeyTitle()));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java
index 0cf30de..6a8b44d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/SshPanel.java
@@ -68,7 +68,7 @@
     showAddKeyBlock.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             showAddKeyBlock(true);
           }
         });
@@ -82,7 +82,7 @@
       deleteKey.addClickHandler(
           new ClickHandler() {
             @Override
-            public void onClick(final ClickEvent event) {
+            public void onClick(ClickEvent event) {
               keys.deleteChecked();
             }
           });
@@ -114,7 +114,7 @@
     clearNew.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             addTxt.setText("");
             addTxt.setFocus(true);
           }
@@ -125,7 +125,7 @@
     addNew.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doAddNew();
           }
         });
@@ -135,7 +135,7 @@
     closeAddKeyBlock.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             showAddKeyBlock(false);
           }
         });
@@ -151,7 +151,7 @@
     initWidget(body);
   }
 
-  void setKeyTableVisible(final boolean on) {
+  void setKeyTableVisible(boolean on) {
     keys.setVisible(on);
     deleteKey.setVisible(on);
     closeAddKeyBlock.setVisible(on);
@@ -166,7 +166,7 @@
           txt,
           new GerritCallback<SshKeyInfo>() {
             @Override
-            public void onSuccess(final SshKeyInfo k) {
+            public void onSuccess(SshKeyInfo k) {
               addNew.setEnabled(true);
               addTxt.setText("");
               keys.addOneKey(k);
@@ -178,7 +178,7 @@
             }
 
             @Override
-            public void onFailure(final Throwable caught) {
+            public void onFailure(Throwable caught) {
               addNew.setEnabled(true);
 
               if (isInvalidSshKey(caught)) {
@@ -189,7 +189,7 @@
               }
             }
 
-            private boolean isInvalidSshKey(final Throwable caught) {
+            private boolean isInvalidSshKey(Throwable caught) {
               if (caught instanceof InvalidSshKeyException) {
                 return true;
               }
@@ -207,9 +207,9 @@
     Gerrit.SYSTEM_SVC.daemonHostKeys(
         new GerritCallback<List<SshHostKey>>() {
           @Override
-          public void onSuccess(final List<SshHostKey> result) {
+          public void onSuccess(List<SshHostKey> result) {
             serverKeys.clear();
-            for (final SshHostKey keyInfo : result) {
+            for (SshHostKey keyInfo : result) {
               serverKeys.add(new SshHostKeyPanel(keyInfo));
             }
             if (++loadCount == 2) {
@@ -238,7 +238,7 @@
 
   void display() {}
 
-  private void showAddKeyBlock(final boolean show) {
+  private void showAddKeyBlock(boolean show) {
     showAddKeyBlock.setVisible(!show);
     addKeyBlock.setVisible(show);
   }
@@ -312,7 +312,7 @@
       }
     }
 
-    void display(final List<SshKeyInfo> result) {
+    void display(List<SshKeyInfo> result) {
       if (result.isEmpty()) {
         setKeyTableVisible(false);
         showAddKeyBlock(true);
@@ -320,7 +320,7 @@
         while (1 < table.getRowCount()) {
           table.removeRow(table.getRowCount() - 1);
         }
-        for (final SshKeyInfo k : result) {
+        for (SshKeyInfo k : result) {
           addOneKey(k);
         }
         setKeyTableVisible(true);
@@ -328,7 +328,7 @@
       }
     }
 
-    void addOneKey(final SshKeyInfo k) {
+    void addOneKey(SshKeyInfo k) {
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
       final int row = table.getRowCount();
       table.insertRow(row);
@@ -378,7 +378,7 @@
     }
   }
 
-  static String elide(final String s, final int len) {
+  static String elide(String s, int len) {
     if (s == null || s.length() < len || len <= 10) {
       return s;
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
index e201a8f..def29b2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/UsernameField.java
@@ -80,7 +80,7 @@
       setUserName.addClickHandler(
           new ClickHandler() {
             @Override
-            public void onClick(final ClickEvent event) {
+            public void onClick(ClickEvent event) {
               confirmSetUserName();
             }
           });
@@ -148,14 +148,14 @@
         });
   }
 
-  private void enableUI(final boolean on) {
+  private void enableUI(boolean on) {
     userNameTxt.setEnabled(on);
     setUserName.setEnabled(on);
   }
 
   private static final class UserNameValidator implements KeyPressHandler {
     @Override
-    public void onKeyPress(final KeyPressEvent event) {
+    public void onKeyPress(KeyPressEvent event) {
       final char code = event.getCharCode();
       final int nativeCode = event.getNativeEvent().getKeyCode();
       switch (nativeCode) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java
index 990798c..b66f108 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/ValidateEmailScreen.java
@@ -24,7 +24,7 @@
 public class ValidateEmailScreen extends AccountScreen {
   private final String magicToken;
 
-  public ValidateEmailScreen(final String magicToken) {
+  public ValidateEmailScreen(String magicToken) {
     this.magicToken = magicToken;
   }
 
@@ -41,7 +41,7 @@
         magicToken,
         new ScreenLoadCallback<VoidResult>(this) {
           @Override
-          protected void preDisplay(final VoidResult result) {}
+          protected void preDisplay(VoidResult result) {}
 
           @Override
           protected void postDisplay() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
index 37813af..e518d26 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
@@ -204,7 +204,7 @@
     }
   }
 
-  void setEditing(final boolean editing) {
+  void setEditing(boolean editing) {
     this.editing = editing;
   }
 
@@ -236,7 +236,7 @@
     }
   }
 
-  private void addPermission(final String permissionName, final List<String> permissionList) {
+  private void addPermission(String permissionName, List<String> permissionList) {
     if (value.getPermission(permissionName) != null) {
       return;
     }
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 4d1ad22..34a1ac9 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
@@ -48,7 +48,7 @@
   private CheckBox visibleToAllCheckBox;
   private Button saveGroupOptions;
 
-  public AccountGroupInfoScreen(final GroupInfo toShow, final String token) {
+  public AccountGroupInfoScreen(GroupInfo toShow, String token) {
     super(toShow, token);
   }
 
@@ -62,7 +62,7 @@
     initGroupOptions();
   }
 
-  private void enableForm(final boolean canModify) {
+  private void enableForm(boolean canModify) {
     groupNameTxt.setEnabled(canModify);
     ownerTxt.setEnabled(canModify);
     descTxt.setEnabled(canModify);
@@ -91,14 +91,14 @@
     saveName.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             final String newName = groupNameTxt.getText().trim();
             GroupApi.renameGroup(
                 getGroupUUID(),
                 newName,
                 new GerritCallback<com.google.gerrit.client.VoidResult>() {
                   @Override
-                  public void onSuccess(final com.google.gerrit.client.VoidResult result) {
+                  public void onSuccess(com.google.gerrit.client.VoidResult result) {
                     saveName.setEnabled(false);
                     setPageTitle(AdminMessages.I.group(newName));
                     groupNameTxt.setText(newName);
@@ -129,7 +129,7 @@
     saveOwner.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             final String newOwner = ownerTxt.getText().trim();
             if (newOwner.length() > 0) {
               AccountGroup.UUID ownerUuid = accountGroupOracle.getUUID(newOwner);
@@ -139,7 +139,7 @@
                   ownerId,
                   new GerritCallback<GroupInfo>() {
                     @Override
-                    public void onSuccess(final GroupInfo result) {
+                    public void onSuccess(GroupInfo result) {
                       updateOwnerGroup(result);
                       saveOwner.setEnabled(false);
                     }
@@ -166,14 +166,14 @@
     saveDesc.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             final String txt = descTxt.getText().trim();
             GroupApi.setGroupDescription(
                 getGroupUUID(),
                 txt,
                 new GerritCallback<VoidResult>() {
                   @Override
-                  public void onSuccess(final VoidResult result) {
+                  public void onSuccess(VoidResult result) {
                     saveDesc.setEnabled(false);
                   }
                 });
@@ -199,13 +199,13 @@
     saveGroupOptions.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             GroupApi.setGroupOptions(
                 getGroupUUID(),
                 visibleToAllCheckBox.getValue(),
                 new GerritCallback<VoidResult>() {
                   @Override
-                  public void onSuccess(final VoidResult result) {
+                  public void onSuccess(VoidResult result) {
                     saveGroupOptions.setEnabled(false);
                   }
                 });
@@ -220,7 +220,7 @@
   }
 
   @Override
-  protected void display(final GroupInfo group, final boolean canModify) {
+  protected void display(GroupInfo group, boolean canModify) {
     groupUUIDLabel.setText(group.getGroupUUID().get());
     groupNameTxt.setText(group.name());
     ownerTxt.setText(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
index 51b4979..2614224 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccountGroupMembersScreen.java
@@ -59,7 +59,7 @@
   private FlowPanel noMembersInfo;
   private AccountGroupSuggestOracle accountGroupSuggestOracle;
 
-  public AccountGroupMembersScreen(final GroupInfo toShow, final String token) {
+  public AccountGroupMembersScreen(GroupInfo toShow, String token) {
     super(toShow, token);
   }
 
@@ -71,7 +71,7 @@
     initNoMembersInfo();
   }
 
-  private void enableForm(final boolean canModify) {
+  private void enableForm(boolean canModify) {
     addMemberBox.setEnabled(canModify);
     members.setEnabled(canModify);
     addIncludeBox.setEnabled(canModify);
@@ -88,7 +88,7 @@
     addMemberBox.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doAddNewMember();
           }
         });
@@ -100,7 +100,7 @@
     delMember.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             members.deleteChecked();
           }
         });
@@ -124,7 +124,7 @@
     addIncludeBox.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doAddNewInclude();
           }
         });
@@ -136,7 +136,7 @@
     delInclude.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             includes.deleteChecked();
           }
         });
@@ -157,7 +157,7 @@
   }
 
   @Override
-  protected void display(final GroupInfo group, final boolean canModify) {
+  protected void display(GroupInfo group, boolean canModify) {
     if (AccountGroup.isInternalGroup(group.getGroupUUID())) {
       members.display(Natives.asList(group.members()));
       includes.display(Natives.asList(group.includes()));
@@ -184,14 +184,14 @@
         nameEmail,
         new GerritCallback<AccountInfo>() {
           @Override
-          public void onSuccess(final AccountInfo memberInfo) {
+          public void onSuccess(AccountInfo memberInfo) {
             addMemberBox.setEnabled(true);
             addMemberBox.setText("");
             members.insert(memberInfo);
           }
 
           @Override
-          public void onFailure(final Throwable caught) {
+          public void onFailure(Throwable caught) {
             addMemberBox.setEnabled(true);
             super.onFailure(caught);
           }
@@ -215,14 +215,14 @@
         uuid.get(),
         new GerritCallback<GroupInfo>() {
           @Override
-          public void onSuccess(final GroupInfo result) {
+          public void onSuccess(GroupInfo result) {
             addIncludeBox.setEnabled(true);
             addIncludeBox.setText("");
             includes.insert(result);
           }
 
           @Override
-          public void onFailure(final Throwable caught) {
+          public void onFailure(Throwable caught) {
             addIncludeBox.setEnabled(true);
             super.onFailure(caught);
           }
@@ -242,7 +242,7 @@
       fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
     }
 
-    void setEnabled(final boolean enabled) {
+    void setEnabled(boolean enabled) {
       this.enabled = enabled;
       for (int row = 1; row < table.getRowCount(); row++) {
         final AccountInfo i = getRowItem(row);
@@ -266,7 +266,7 @@
             ids,
             new GerritCallback<VoidResult>() {
               @Override
-              public void onSuccess(final VoidResult result) {
+              public void onSuccess(VoidResult result) {
                 for (int row = 1; row < table.getRowCount(); ) {
                   final AccountInfo i = getRowItem(row);
                   if (i != null && ids.contains(i._accountId())) {
@@ -280,12 +280,12 @@
       }
     }
 
-    void display(final List<AccountInfo> result) {
+    void display(List<AccountInfo> result) {
       while (1 < table.getRowCount()) {
         table.removeRow(table.getRowCount() - 1);
       }
 
-      for (final AccountInfo i : result) {
+      for (AccountInfo i : result) {
         final int row = table.getRowCount();
         table.insertRow(row);
         applyDataRowStyle(row);
@@ -323,7 +323,7 @@
       }
     }
 
-    void populate(final int row, final AccountInfo i) {
+    void populate(int row, AccountInfo i) {
       CheckBox checkBox = new CheckBox();
       table.setWidget(row, 1, checkBox);
       checkBox.setEnabled(enabled);
@@ -352,7 +352,7 @@
       fmt.addStyleName(0, 3, Gerrit.RESOURCES.css().dataHeader());
     }
 
-    void setEnabled(final boolean enabled) {
+    void setEnabled(boolean enabled) {
       this.enabled = enabled;
       for (int row = 1; row < table.getRowCount(); row++) {
         final GroupInfo i = getRowItem(row);
@@ -376,7 +376,7 @@
             ids,
             new GerritCallback<VoidResult>() {
               @Override
-              public void onSuccess(final VoidResult result) {
+              public void onSuccess(VoidResult result) {
                 for (int row = 1; row < table.getRowCount(); ) {
                   final GroupInfo i = getRowItem(row);
                   if (i != null && ids.contains(i.getGroupUUID())) {
@@ -395,7 +395,7 @@
         table.removeRow(table.getRowCount() - 1);
       }
 
-      for (final GroupInfo i : list) {
+      for (GroupInfo i : list) {
         final int row = table.getRowCount();
         table.insertRow(row);
         applyDataRowStyle(row);
@@ -427,7 +427,7 @@
       }
     }
 
-    void populate(final int row, final GroupInfo i) {
+    void populate(int row, GroupInfo i) {
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
 
       AccountGroup.UUID uuid = i.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 29b7677..b67213b 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
@@ -32,7 +32,7 @@
   private final String membersTabToken;
   private final String auditLogTabToken;
 
-  public AccountGroupScreen(final GroupInfo toShow, final String token) {
+  public AccountGroupScreen(GroupInfo toShow, String token) {
     setRequiresSignIn(true);
 
     this.group = toShow;
@@ -47,7 +47,7 @@
         AccountGroup.isInternalGroup(group.getGroupUUID()));
   }
 
-  private String getTabToken(final String token, final String tab) {
+  private String getTabToken(String token, String tab) {
     if (token.startsWith("/admin/groups/uuid-")) {
       return toGroup(group.getGroupUUID(), tab);
     }
@@ -91,7 +91,7 @@
     return group.getOwnerUUID();
   }
 
-  protected void setMembersTabVisible(final boolean visible) {
+  protected void setMembersTabVisible(boolean visible) {
     setLinkVisible(membersTabToken, visible);
   }
 }
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 14e7abc..9d33f5a 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
@@ -77,6 +77,14 @@
 
   String rejectImplicitMerges();
 
+  String privateByDefault();
+
+  String workInProgressByDefault();
+
+  String enableReviewerByEmail();
+
+  String matchAuthorToCommitterDate();
+
   String headingMaxObjectSizeLimit();
 
   String headingGroupOptions();
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 32203bf..527cb1e 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
@@ -30,6 +30,8 @@
 requireSignedPush = Require signed push
 requireChangeID = Require <code>Change-Id</code> in commit message
 rejectImplicitMerges = Reject implicit merges when changes are pushed for review
+privateByDefault = Set all new changes private by default
+workInProgressByDefault = Set all new changes work-in-progress by default
 headingMaxObjectSizeLimit = Maximum Git object size limit
 headingGroupOptions = Group Options
 isVisibleToAll = Make group visible to all registered users.
@@ -38,6 +40,8 @@
 headingParentProjectName = Rights Inherit From
 parentSuggestions = Parent Suggestion
 columnProjectName = Project Name
+enableReviewerByEmail = Enable adding unregistered users as reviewers and CCs on changes
+matchAuthorToCommitterDate = Match authored date with committer date upon submit
 
 headingGroupUUID = Group UUID
 headingOwner = Owners
@@ -135,7 +139,6 @@
 	createSignedTag, \
 	delete, \
 	deleteChanges, \
-	deleteDrafts, \
 	deleteOwnChanges, \
 	editAssignee, \
 	editHashtags, \
@@ -144,7 +147,6 @@
 	forgeCommitter, \
 	forgeServerAsCommitter, \
 	owner, \
-	publishDrafts, \
 	push, \
 	pushMerge, \
 	read, \
@@ -152,7 +154,7 @@
 	removeReviewer, \
 	submit, \
 	submitAs, \
-	viewDrafts
+	viewPrivateChanges
 
 abandon = Abandon
 addPatchSet = Add Patch Set
@@ -161,7 +163,6 @@
 createSignedTag = Create Signed Tag
 delete = Delete Reference
 deleteChanges = Delete Changes
-deleteDrafts = Delete Drafts
 deleteOwnChanges = Delete Own Changes
 editAssignee = Edit Assignee
 editHashtags = Edit Hashtags
@@ -170,7 +171,6 @@
 forgeCommitter = Forge Committer Identity
 forgeServerAsCommitter = Forge Server Identity
 owner = Owner
-publishDrafts = Publish Drafts
 push = Push
 pushMerge = Push Merge Commit
 read = Read
@@ -178,7 +178,7 @@
 removeReviewer = Remove Reviewer
 submit = Submit
 submitAs = Submit (On Behalf Of)
-viewDrafts = View Drafts
+viewPrivateChanges = View Private Changes
 
 refErrorEmpty = Reference must be supplied
 refErrorBeginSlash = Reference must not start with '/'
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java
index 2e5bbb5..611db85 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateChangeAction.java
@@ -26,7 +26,7 @@
 import com.google.gwt.user.client.ui.PopupPanel;
 
 class CreateChangeAction {
-  static void call(final Button b, final String project) {
+  static void call(Button b, String project) {
     // TODO Replace CreateChangeDialog with a nicer looking display.
     b.setEnabled(false);
     new CreateChangeDialog(new Project.NameKey(project)) {
@@ -48,7 +48,7 @@
               public void onSuccess(ChangeInfo result) {
                 sent = true;
                 hide();
-                Gerrit.display(PageLinks.toChange(result.legacyId()));
+                Gerrit.display(PageLinks.toChange(result.projectNameKey(), result.legacyId()));
               }
 
               @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
index 457e179..6914ee9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateGroupScreen.java
@@ -74,6 +74,14 @@
     addCreateGroupPanel();
   }
 
+  @Override
+  public void onShowView() {
+    super.onShowView();
+    if (addTxt != null) {
+      addTxt.setFocus(true);
+    }
+  }
+
   private void addCreateGroupPanel() {
     VerticalPanel addPanel = new VerticalPanel();
     addPanel.setStyleName(Gerrit.RESOURCES.css().addSshKeyPanel());
@@ -117,7 +125,7 @@
     addNew.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doCreateGroup();
           }
         });
@@ -138,7 +146,7 @@
         newName,
         new GerritCallback<GroupInfo>() {
           @Override
-          public void onSuccess(final GroupInfo result) {
+          public void onSuccess(GroupInfo result) {
             History.newItem(Dispatcher.toGroup(result.getGroupId(), AccountGroupScreen.MEMBERS));
           }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
index 092c6e1..02b9169 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
@@ -111,6 +111,14 @@
     projectsPopup.initPopup(AdminConstants.I.projects(), PageLinks.ADMIN_PROJECTS);
   }
 
+  @Override
+  public void onShowView() {
+    super.onShowView();
+    if (project != null) {
+      project.setFocus(true);
+    }
+  }
+
   private void addCreateProjectPanel() {
     final VerticalPanel fp = new VerticalPanel();
     fp.setStyleName(Gerrit.RESOURCES.css().createProjectPanel());
@@ -122,6 +130,7 @@
     addGrid(fp);
 
     emptyCommit = new CheckBox(AdminConstants.I.checkBoxEmptyCommit());
+    emptyCommit.setValue(true);
     permissionsOnly = new CheckBox(AdminConstants.I.checkBoxPermissionsOnly());
     fp.add(emptyCommit);
     fp.add(permissionsOnly);
@@ -173,7 +182,7 @@
     create.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doCreateProject();
           }
         });
@@ -182,7 +191,7 @@
     browse.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             int top = grid.getAbsoluteTop() - 50; // under page header
             // Try to place it to the right of everything else, but not
             // right justified
@@ -211,7 +220,7 @@
           }
 
           @Override
-          protected void populate(final int row, final ProjectInfo k) {
+          protected void populate(int row, ProjectInfo k) {
             populateState(row, k);
             final Anchor projectLink = new Anchor(k.name());
             projectLink.addClickHandler(
@@ -244,7 +253,7 @@
         });
   }
 
-  private void addGrid(final VerticalPanel fp) {
+  private void addGrid(VerticalPanel fp) {
     grid = new Grid(2, 3);
     grid.setStyleName(Gerrit.RESOURCES.css().infoBlock());
     grid.setText(0, 0, AdminConstants.I.columnProjectName() + ":");
@@ -287,7 +296,7 @@
         });
   }
 
-  private void enableForm(final boolean enabled) {
+  private void enableForm(boolean enabled) {
     project.setEnabled(enabled);
     create.setEnabled(enabled);
     parent.setEnabled(enabled);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java
index d28e9bb..cb2ca0f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/EditConfigAction.java
@@ -20,15 +20,17 @@
 import com.google.gerrit.client.info.ChangeInfo;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gwt.user.client.ui.Button;
 
 public class EditConfigAction {
-  static void call(final Button b, final String project) {
+
+  static void call(Button b, Project.NameKey project) {
     b.setEnabled(false);
 
     ChangeApi.createChange(
-        project,
+        project.get(),
         RefNames.REFS_CONFIG,
         null,
         AdminConstants.I.editConfigMessage(),
@@ -37,7 +39,8 @@
           @Override
           public void onSuccess(ChangeInfo result) {
             Gerrit.display(
-                Dispatcher.toEditScreen(new PatchSet.Id(result.legacyId(), 1), "project.config"));
+                Dispatcher.toEditScreen(
+                    project, new PatchSet.Id(result.legacyId(), 1), "project.config"));
           }
 
           @Override
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 0f5bf22..259847e 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
@@ -43,7 +43,7 @@
     this(null);
   }
 
-  public GroupTable(final String pointerId) {
+  public GroupTable(String pointerId) {
     super(AdminConstants.I.groupItemHelp());
     setSavePointerId(pointerId);
 
@@ -70,12 +70,12 @@
   }
 
   @Override
-  protected Object getRowItemKey(final GroupInfo item) {
+  protected Object getRowItemKey(GroupInfo item) {
     return item.getGroupId();
   }
 
   @Override
-  protected void onOpenRow(final int row) {
+  protected void onOpenRow(int row) {
     GroupInfo groupInfo = getRowItem(row);
     if (isInteralGroup(groupInfo)) {
       History.newItem(Dispatcher.toGroup(groupInfo.getGroupId()));
@@ -121,7 +121,7 @@
     }
   }
 
-  void populate(final int row, final GroupInfo k, final String toHighlight) {
+  void populate(int row, GroupInfo k, String toHighlight) {
     if (k.url() != null) {
       if (isInteralGroup(k)) {
         table.setWidget(
@@ -152,7 +152,7 @@
     setRowItem(row, k);
   }
 
-  private boolean isInteralGroup(final GroupInfo groupInfo) {
+  private boolean isInteralGroup(GroupInfo groupInfo) {
     return groupInfo != null && groupInfo.url().startsWith("#" + PageLinks.ADMIN_GROUPS);
   }
 }
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 d254c7d..79a4cef 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
@@ -205,7 +205,7 @@
     addStage2.getStyle().setDisplay(Display.NONE);
   }
 
-  private void addGroup(final GroupReference ref) {
+  private void addGroup(GroupReference ref) {
     if (ref.getUUID() != null) {
       if (value.getRule(ref) == null) {
         PermissionRule newRule = value.getRule(ref, true);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java
index 8a70f2e..381c644 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/PluginListScreen.java
@@ -45,7 +45,7 @@
     PluginMap.all(
         new ScreenLoadCallback<PluginMap>(this) {
           @Override
-          protected void preDisplay(final PluginMap result) {
+          protected void preDisplay(PluginMap result) {
             pluginTable.display(result);
           }
         });
@@ -75,12 +75,12 @@
       fmt.addStyleName(0, 4, Gerrit.RESOURCES.css().dataHeader());
     }
 
-    void display(final PluginMap plugins) {
+    void display(PluginMap plugins) {
       while (1 < table.getRowCount()) {
         table.removeRow(table.getRowCount() - 1);
       }
 
-      for (final PluginInfo p : Natives.asList(plugins.values())) {
+      for (PluginInfo p : Natives.asList(plugins.values())) {
         final int row = table.getRowCount();
         table.insertRow(row);
         applyDataRowStyle(row);
@@ -88,7 +88,7 @@
       }
     }
 
-    void populate(final int row, final PluginInfo plugin) {
+    void populate(int row, PluginInfo plugin) {
       if (plugin.disabled() || plugin.indexUrl() == null) {
         table.setText(row, 1, plugin.name());
       } else {
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 05142c4..a52ea60 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
@@ -134,7 +134,7 @@
   @Override
   public void setDelegate(EditorDelegate<ProjectAccess> delegate) {}
 
-  void setEditing(final boolean editing) {
+  void setEditing(boolean editing) {
     this.editing = editing;
     addSection.setVisible(editing);
   }
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 0398e9d..eb44bda 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
@@ -91,7 +91,7 @@
 
   private NativeMap<CapabilityInfo> capabilityMap;
 
-  public ProjectAccessScreen(final Project.NameKey toShow) {
+  public ProjectAccessScreen(Project.NameKey toShow) {
     super(toShow);
   }
 
@@ -211,7 +211,7 @@
               displayReadOnly(newAccess);
             } else {
               error.add(new Label(Gerrit.C.projectAccessError()));
-              for (final String diff : diffs) {
+              for (String diff : diffs) {
                 error.add(new Label(diff));
               }
               if (access.canUpload()) {
@@ -287,7 +287,7 @@
             commitMessage.setText("");
             error.clear();
             if (changeId != null) {
-              Gerrit.display(PageLinks.toChange(changeId));
+              Gerrit.display(PageLinks.toChange(getProjectKey(), changeId));
             } else {
               displayReadOnly(access);
             }
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 8ff1164..c6a391b 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
@@ -82,7 +82,7 @@
   private NpTextBox filterTxt;
   private Query query;
 
-  public ProjectBranchesScreen(final Project.NameKey toShow) {
+  public ProjectBranchesScreen(Project.NameKey toShow) {
     super(toShow);
   }
 
@@ -165,7 +165,7 @@
     addBranch.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             doAddNewBranch();
           }
         });
@@ -179,7 +179,7 @@
     delBranch.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             branchTable.deleteChecked();
           }
         });
@@ -384,7 +384,7 @@
       confirmationDialog.center();
     }
 
-    private void deleteBranches(final Set<String> branches) {
+    private void deleteBranches(Set<String> branches) {
       ProjectApi.deleteBranches(
           getProjectKey(),
           branches,
@@ -473,7 +473,7 @@
       setRowItem(row, k);
     }
 
-    private void setHeadRevision(final int row, final int column, final String rev) {
+    private void setHeadRevision(int row, int column, String rev) {
       AccessMap.get(
           getProjectKey(),
           new GerritCallback<ProjectAccessInfo>() {
@@ -488,7 +488,7 @@
           });
     }
 
-    private Widget getHeadRevisionWidget(final String headRevision) {
+    private Widget getHeadRevisionWidget(String headRevision) {
       FlowPanel p = new FlowPanel();
       final InlineLabel l = new InlineLabel(headRevision);
       final Image edit = new Image(Gerrit.RESOURCES.edit());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java
index 52fe3399..7b5d04d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectDashboardsScreen.java
@@ -25,7 +25,7 @@
   private DashboardsTable dashes;
   Project.NameKey project;
 
-  public ProjectDashboardsScreen(final Project.NameKey project) {
+  public ProjectDashboardsScreen(Project.NameKey project) {
     super(project);
     this.project = project;
   }
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 2e4054e..c54a41b 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
@@ -86,6 +86,10 @@
   private ListBox enableSignedPush;
   private ListBox requireSignedPush;
   private ListBox rejectImplicitMerges;
+  private ListBox privateByDefault;
+  private ListBox workInProgressByDefault;
+  private ListBox enableReviewerByEmail;
+  private ListBox matchAuthorToCommitterDate;
   private NpTextBox maxObjectSizeLimit;
   private Label effectiveMaxObjectSizeLimit;
   private Map<String, Map<String, HasEnabled>> pluginConfigWidgets;
@@ -99,7 +103,7 @@
 
   private OnEditEnabler saveEnabler;
 
-  public ProjectInfoScreen(final Project.NameKey toShow) {
+  public ProjectInfoScreen(Project.NameKey toShow) {
     super(toShow);
   }
 
@@ -193,7 +197,11 @@
     signedOffBy.setEnabled(isOwner);
     requireChangeID.setEnabled(isOwner);
     rejectImplicitMerges.setEnabled(isOwner);
+    privateByDefault.setEnabled(isOwner);
+    workInProgressByDefault.setEnabled(isOwner);
     maxObjectSizeLimit.setEnabled(isOwner);
+    enableReviewerByEmail.setEnabled(isOwner);
+    matchAuthorToCommitterDate.setEnabled(isOwner);
 
     if (pluginConfigWidgets != null) {
       for (Map<String, HasEnabled> widgetMap : pluginConfigWidgets.values()) {
@@ -229,7 +237,7 @@
     grid.add(AdminConstants.I.headingProjectState(), state);
 
     submitType = new ListBox();
-    for (final SubmitType type : SubmitType.values()) {
+    for (SubmitType type : SubmitType.values()) {
       submitType.addItem(Util.toLongString(type), type.name());
     }
     submitType.addChangeHandler(
@@ -267,6 +275,22 @@
     saveEnabler.listenTo(rejectImplicitMerges);
     grid.addHtml(AdminConstants.I.rejectImplicitMerges(), rejectImplicitMerges);
 
+    privateByDefault = newInheritedBooleanBox();
+    saveEnabler.listenTo(privateByDefault);
+    grid.addHtml(AdminConstants.I.privateByDefault(), privateByDefault);
+
+    workInProgressByDefault = newInheritedBooleanBox();
+    saveEnabler.listenTo(workInProgressByDefault);
+    grid.addHtml(AdminConstants.I.workInProgressByDefault(), workInProgressByDefault);
+
+    enableReviewerByEmail = newInheritedBooleanBox();
+    saveEnabler.listenTo(enableReviewerByEmail);
+    grid.addHtml(AdminConstants.I.enableReviewerByEmail(), enableReviewerByEmail);
+
+    matchAuthorToCommitterDate = newInheritedBooleanBox();
+    saveEnabler.listenTo(matchAuthorToCommitterDate);
+    grid.addHtml(AdminConstants.I.matchAuthorToCommitterDate(), matchAuthorToCommitterDate);
+
     maxObjectSizeLimit = new NpTextBox();
     saveEnabler.listenTo(maxObjectSizeLimit);
     effectiveMaxObjectSizeLimit = new Label();
@@ -317,7 +341,7 @@
     grid.addHtml(AdminConstants.I.useSignedOffBy(), signedOffBy);
   }
 
-  private void setSubmitType(final SubmitType newSubmitType) {
+  private void setSubmitType(SubmitType newSubmitType) {
     int index = -1;
     if (submitType != null) {
       for (int i = 0; i < submitType.getItemCount(); i++) {
@@ -331,7 +355,7 @@
     }
   }
 
-  private void setState(final ProjectState newState) {
+  private void setState(ProjectState newState) {
     if (state != null) {
       for (int i = 0; i < state.getItemCount(); i++) {
         if (newState.name().equals(state.getValue(i))) {
@@ -398,6 +422,10 @@
       setBool(requireSignedPush, result.requireSignedPush());
     }
     setBool(rejectImplicitMerges, result.rejectImplicitMerges());
+    setBool(privateByDefault, result.privateByDefault());
+    setBool(workInProgressByDefault, result.workInProgressByDefault());
+    setBool(enableReviewerByEmail, result.enableReviewerByEmail());
+    setBool(matchAuthorToCommitterDate, result.matchAuthorToCommitterDate());
     setSubmitType(result.submitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
@@ -649,7 +677,7 @@
         new ClickHandler() {
           @Override
           public void onClick(ClickEvent event) {
-            EditConfigAction.call(editConfig, getProjectKey().get());
+            EditConfigAction.call(editConfig, getProjectKey());
           }
         });
     return editConfig;
@@ -671,6 +699,10 @@
         esp,
         rsp,
         getBool(rejectImplicitMerges),
+        getBool(privateByDefault),
+        getBool(workInProgressByDefault),
+        getBool(enableReviewerByEmail),
+        getBool(matchAuthorToCommitterDate),
         maxObjectSizeLimit.getText().trim(),
         SubmitType.valueOf(submitType.getValue(submitType.getSelectedIndex())),
         ProjectState.valueOf(state.getValue(state.getSelectedIndex())),
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 9166c56..2a03136 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
@@ -91,11 +91,11 @@
           }
 
           @Override
-          protected void onOpenRow(final int row) {
+          protected void onOpenRow(int row) {
             History.newItem(link(getRowItem(row)));
           }
 
-          private String link(final ProjectInfo item) {
+          private String link(ProjectInfo item) {
             return Dispatcher.toProject(item.name_key());
           }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java
index 3328163..dc964b8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectScreen.java
@@ -37,7 +37,7 @@
 
   private final Project.NameKey name;
 
-  public ProjectScreen(final Project.NameKey toShow) {
+  public ProjectScreen(Project.NameKey toShow) {
     name = toShow;
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
index f66f42b..22c331d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
@@ -388,7 +388,7 @@
       confirmationDialog.center();
     }
 
-    private void deleteTags(final Set<String> tags) {
+    private void deleteTags(Set<String> tags) {
       ProjectApi.deleteTags(
           getProjectKey(),
           tags,
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
index f08cdd8..2e4926d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/Util.java
@@ -30,7 +30,7 @@
     AdminResources.I.css().ensureInjected();
   }
 
-  public static String toLongString(final SubmitType type) {
+  public static String toLongString(SubmitType type) {
     if (type == null) {
       return "";
     }
@@ -52,7 +52,7 @@
     }
   }
 
-  public static String toLongString(final ProjectState type) {
+  public static String toLongString(ProjectState type) {
     if (type == null) {
       return "";
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java
index 7e1db46..cf8de54 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ActionContext.java
@@ -176,7 +176,7 @@
    * The same as {@link #get(RestApi, JavaScriptObject)} but without converting a {@link
    * NativeString} result to String.
    */
-  static final void getRaw(RestApi api, final JavaScriptObject cb) {
+  static final void getRaw(RestApi api, JavaScriptObject cb) {
     api.get(wrapRaw(cb));
   }
 
@@ -268,7 +268,7 @@
     api.delete(wrapRaw(cb));
   }
 
-  private static GerritCallback<JavaScriptObject> wrap(final JavaScriptObject cb) {
+  private static GerritCallback<JavaScriptObject> wrap(JavaScriptObject cb) {
     return new GerritCallback<JavaScriptObject>() {
       @Override
       public void onSuccess(JavaScriptObject result) {
@@ -282,7 +282,7 @@
     };
   }
 
-  private static GerritCallback<JavaScriptObject> wrapRaw(final JavaScriptObject cb) {
+  private static GerritCallback<JavaScriptObject> wrapRaw(JavaScriptObject cb) {
     return new GerritCallback<JavaScriptObject>() {
       @Override
       public void onSuccess(JavaScriptObject result) {
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 1555f56..294fa9b 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
@@ -100,9 +100,9 @@
         var s = new SettingsScreenDefinition(p,m,c);
         (this.settingsScreens[n] || (this.settingsScreens[n]=[])).push(s);
       },
-      panel: function(i,c){this._panel(this.getPluginName(),i,c)},
-      _panel: function(n,i,c){
-        var p = new PanelDefinition(n,c);
+      panel: function(i,c,n){this._panel(this.getPluginName(),i,c,n)},
+      _panel: function(n,i,c,x){
+        var p = new PanelDefinition(n,c,x);
         (this.panels[i] || (this.panels[i]=[])).push(p);
       },
 
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 6bba958..c7f0051 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
@@ -39,7 +39,7 @@
   }
 
   public static void onAction(ChangeInfo change, ActionInfo action, ActionButton button) {
-    RestApi api = ChangeApi.change(change.legacyId().get()).view(action.id());
+    RestApi api = ChangeApi.change(change.project(), change.legacyId().get()).view(action.id());
     JavaScriptObject f = get(action.id());
     if (f != null) {
       ActionContext c = ActionContext.create(api);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
index 74668c1..0c4aacd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
@@ -29,18 +29,23 @@
 
 class DefaultActions {
   static void invoke(ChangeInfo change, ActionInfo action, RestApi api) {
-    invoke(action, api, callback(PageLinks.toChange(change.legacyId())));
+    invoke(action, api, callback(PageLinks.toChange(change.projectNameKey(), change.legacyId())));
   }
 
   static void invoke(Project.NameKey project, ActionInfo action, RestApi api) {
     invoke(action, api, callback(PageLinks.toProject(project)));
   }
 
-  private static AsyncCallback<JavaScriptObject> callback(final String target) {
+  private static AsyncCallback<JavaScriptObject> callback(String target) {
     return new GerritCallback<JavaScriptObject>() {
       @Override
       public void onSuccess(JavaScriptObject in) {
         UiResult result = asUiResult(in);
+        if (result == null) {
+          Gerrit.display(target);
+          return;
+        }
+
         if (result.alert() != null) {
           Window.alert(result.alert());
         }
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 2d9a76a..85cfde6 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
@@ -25,7 +25,7 @@
 public class EditGlue {
   public static void onAction(
       ChangeInfo change, EditInfo edit, ActionInfo action, ActionButton button) {
-    RestApi api = ChangeApi.edit(change.legacyId().get()).view(action.id());
+    RestApi api = ChangeApi.edit(change.project(), change.legacyId().get()).view(action.id());
 
     JavaScriptObject f = get(action.id());
     if (f != null) {
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 0873363..6d3dd60 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
@@ -22,7 +22,10 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.SimplePanel;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
@@ -32,13 +35,17 @@
   private final List<Context> contexts;
 
   public ExtensionPanel(GerritUiExtensionPoint extensionPoint) {
-    this.extensionPoint = extensionPoint;
-    this.contexts = create();
+    this(extensionPoint, new ArrayList<String>());
   }
 
-  private List<Context> create() {
+  public ExtensionPanel(GerritUiExtensionPoint extensionPoint, List<String> panelNames) {
+    this.extensionPoint = extensionPoint;
+    this.contexts = create(panelNames);
+  }
+
+  private List<Context> create(List<String> panelNames) {
     List<Context> contexts = new ArrayList<>();
-    for (Definition def : Natives.asList(Definition.get(extensionPoint.name()))) {
+    for (Definition def : getOrderedDefs(panelNames)) {
       SimplePanel p = new SimplePanel();
       add(p);
       contexts.add(Context.create(def, p));
@@ -46,6 +53,42 @@
     return contexts;
   }
 
+  private List<Definition> getOrderedDefs(List<String> panelNames) {
+    if (panelNames == null) {
+      panelNames = Collections.emptyList();
+    }
+    Map<String, List<Definition>> defsOrderedByName = new LinkedHashMap<>();
+    for (String name : panelNames) {
+      defsOrderedByName.put(name, new ArrayList<Definition>());
+    }
+    for (Definition def : Natives.asList(Definition.get(extensionPoint.name()))) {
+      addDef(def, defsOrderedByName);
+    }
+    List<Definition> orderedDefs = new ArrayList<>();
+    for (List<Definition> defList : defsOrderedByName.values()) {
+      orderedDefs.addAll(defList);
+    }
+    return orderedDefs;
+  }
+
+  private static void addDef(Definition def, Map<String, List<Definition>> defsOrderedByName) {
+    String panelName = def.getPanelName();
+    if (panelName.equals(def.getPluginName() + ".undefined")) {
+      /* Handle a partially undefined panel name from the
+      javascript layer by generating a random panel name.
+      This maintains support for panels that do not provide a name. */
+      panelName =
+          def.getPluginName() + "." + Long.toHexString(Double.doubleToLongBits(Math.random()));
+    }
+    if (defsOrderedByName.containsKey(panelName)) {
+      defsOrderedByName.get(panelName).add(def);
+    } else if (defsOrderedByName.containsKey(def.getPluginName())) {
+      defsOrderedByName.get(def.getPluginName()).add(def);
+    } else {
+      defsOrderedByName.put(panelName, Collections.singletonList(def));
+    }
+  }
+
   public void put(GerritUiExtensionPoint.Key key, String value) {
     for (Context ctx : contexts) {
       ctx.put(key.name(), value);
@@ -103,9 +146,10 @@
     static final JavaScriptObject TYPE = init();
 
     private static native JavaScriptObject init() /*-{
-      function PanelDefinition(n, c) {
+      function PanelDefinition(n, c, x) {
         this.pluginName = n;
         this.onLoad = c;
+        this.name = x;
       };
       return PanelDefinition;
     }-*/;
@@ -113,6 +157,10 @@
     static native JsArray<Definition> get(String i) /*-{ return $wnd.Gerrit.panels[i] || [] }-*/;
 
     protected Definition() {}
+
+    public final native String getPanelName() /*-{ return this.pluginName + "." + this.name; }-*/;
+
+    public final native String getPluginName() /*-{ return this.pluginName; }-*/;
   }
 
   static class Context extends JavaScriptObject {
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 29787b8..48a812c1 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
@@ -68,7 +68,7 @@
       onAction: function(t,n,c){G._onAction(this.name,t,n,c)},
       screen: function(p,c){G._screen(this.name,p,c)},
       settingsScreen: function(p,m,c){G._settingsScreen(this.name,p,m,c)},
-      panel: function(i,c){G._panel(this.name,i,c)},
+      panel: function(i,c,n){G._panel(this.name,i,c,n)},
 
       url: function (u){return G.url(this._url(u))},
       get: function(u,b){@com.google.gerrit.client.api.ActionContext::get(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
index 1c59dac..de25ef0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.client.api;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.client.ErrorDialog;
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.VoidResult;
@@ -38,10 +40,15 @@
     if (plugins == null || plugins.isEmpty()) {
       callback.onSuccess(VoidResult.create());
     } else {
-      self = new PluginLoader(loadTimeout, callback);
-      self.load(plugins);
-      self.startTimers();
-      self.center();
+      plugins = plugins.stream().filter(p -> p.endsWith(".js")).collect(toList());
+      if (plugins.isEmpty()) {
+        callback.onSuccess(VoidResult.create());
+      } else {
+        self = new PluginLoader(loadTimeout, callback);
+        self.load(plugins);
+        self.startTimers();
+        self.center();
+      }
     }
   }
 
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 2d3b393..d1029b2 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
@@ -25,7 +25,9 @@
 public class RevisionGlue {
   public static void onAction(
       ChangeInfo change, RevisionInfo revision, ActionInfo action, ActionButton button) {
-    RestApi api = ChangeApi.revision(change.legacyId().get(), revision.name()).view(action.id());
+    RestApi api =
+        ChangeApi.revision(change.project(), change.legacyId().get(), revision.name())
+            .view(action.id());
 
     JavaScriptObject f = get(action.id());
     if (f != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java
index b445b75..fd58959 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AbandonAction.java
@@ -20,25 +20,29 @@
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.user.client.ui.Button;
 
 class AbandonAction extends ActionMessageBox {
+  private final Project.NameKey project;
   private final Change.Id id;
 
-  AbandonAction(Button b, Change.Id id) {
+  AbandonAction(Button b, Project.NameKey project, Change.Id id) {
     super(b);
+    this.project = project;
     this.id = id;
   }
 
   @Override
   void send(String message) {
     ChangeApi.abandon(
+        project.get(),
         id.get(),
         message,
         new GerritCallback<ChangeInfo>() {
           @Override
           public void onSuccess(ChangeInfo result) {
-            Gerrit.display(PageLinks.toChange(id));
+            Gerrit.display(PageLinks.toChange(project, id));
             hide();
           }
         });
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 ada28af..e4f5e576 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
@@ -22,6 +22,7 @@
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.uibinder.client.UiBinder;
@@ -42,12 +43,14 @@
     "description",
     "followup",
     "hashtags",
+    "move",
     "publish",
     "rebase",
     "restore",
     "revert",
     "submit",
     "topic",
+    "private",
     "/",
   };
 
@@ -56,6 +59,7 @@
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   @UiField Button cherrypick;
+  @UiField Button move;
   @UiField Button rebase;
   @UiField Button revert;
   @UiField Button submit;
@@ -65,6 +69,9 @@
 
   @UiField Button deleteChange;
 
+  @UiField Button markPrivate;
+  @UiField Button unmarkPrivate;
+
   @UiField Button restore;
   private RestoreAction restoreAction;
 
@@ -74,7 +81,7 @@
   private Change.Id changeId;
   private ChangeInfo changeInfo;
   private String revision;
-  private String project;
+  private Project.NameKey project;
   private String topic;
   private String subject;
   private String message;
@@ -95,7 +102,7 @@
     RevisionInfo revInfo = info.revision(revision);
     CommitInfo commit = revInfo.commit();
     changeId = info.legacyId();
-    project = info.project();
+    project = info.projectNameKey();
     topic = info.topic();
     subject = commit.subject();
     message = commit.message();
@@ -119,9 +126,15 @@
     if (hasUser) {
       a2b(actions, "abandon", abandon);
       a2b(actions, "/", deleteChange);
+      a2b(actions, "move", move);
       a2b(actions, "restore", restore);
       a2b(actions, "revert", revert);
       a2b(actions, "followup", followUp);
+      if (info.isPrivate()) {
+        a2b(actions, "private", unmarkPrivate);
+      } else {
+        a2b(actions, "private", markPrivate);
+      }
       for (String id : filterNonCore(actions)) {
         add(new ActionButton(info, actions.get(id)));
       }
@@ -172,7 +185,7 @@
   @UiHandler("followUp")
   void onFollowUp(@SuppressWarnings("unused") ClickEvent e) {
     if (followUpAction == null) {
-      followUpAction = new FollowUpAction(followUp, project, branch, topic, key);
+      followUpAction = new FollowUpAction(followUp, project.get(), branch, topic, key);
     }
     followUpAction.show();
   }
@@ -180,7 +193,7 @@
   @UiHandler("abandon")
   void onAbandon(@SuppressWarnings("unused") ClickEvent e) {
     if (abandonAction == null) {
-      abandonAction = new AbandonAction(abandon, changeId);
+      abandonAction = new AbandonAction(abandon, project, changeId);
     }
     abandonAction.show();
   }
@@ -188,14 +201,24 @@
   @UiHandler("deleteChange")
   void onDeleteChange(@SuppressWarnings("unused") ClickEvent e) {
     if (Window.confirm(Resources.C.deleteChange())) {
-      ChangeActions.delete(changeId, deleteChange);
+      ChangeActions.delete(project, changeId, deleteChange);
     }
   }
 
+  @UiHandler("markPrivate")
+  void onMarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
+    ChangeActions.markPrivate(project, changeId, markPrivate);
+  }
+
+  @UiHandler("unmarkPrivate")
+  void onUnmarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
+    ChangeActions.unmarkPrivate(project, changeId, unmarkPrivate);
+  }
+
   @UiHandler("restore")
   void onRestore(@SuppressWarnings("unused") ClickEvent e) {
     if (restoreAction == null) {
-      restoreAction = new RestoreAction(restore, changeId);
+      restoreAction = new RestoreAction(restore, project, changeId);
     }
     restoreAction.show();
   }
@@ -216,9 +239,14 @@
     CherryPickAction.call(cherrypick, changeInfo, revision, project, message);
   }
 
+  @UiHandler("move")
+  void onMove(@SuppressWarnings("unused") ClickEvent e) {
+    MoveAction.call(move, changeInfo, project);
+  }
+
   @UiHandler("revert")
   void onRevert(@SuppressWarnings("unused") ClickEvent e) {
-    RevertAction.call(revert, changeId, revision, subject);
+    RevertAction.call(revert, changeId, project, revision, subject);
   }
 
   private static void a2b(NativeMap<ActionInfo> actions, String a, Button b) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
index d0e5c3e..8aeba90 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
@@ -63,6 +63,9 @@
     <g:Button ui:field='cherrypick' styleName='' visible='false'>
       <div><ui:msg>Cherry Pick</ui:msg></div>
     </g:Button>
+    <g:Button ui:field='move' styleName='' visible='false'>
+      <div><ui:msg>Move Change</ui:msg></div>
+    </g:Button>
     <g:Button ui:field='rebase' styleName='' visible='false'>
       <div><ui:msg>Rebase</ui:msg></div>
     </g:Button>
@@ -81,6 +84,12 @@
     <g:Button ui:field='followUp' styleName='' visible='false'>
       <div><ui:msg>Follow-Up</ui:msg></div>
     </g:Button>
+    <g:Button ui:field='markPrivate' styleName='' visible='false'>
+      <div><ui:msg>Mark Private</ui:msg></div>
+    </g:Button>
+    <g:Button ui:field='unmarkPrivate' styleName='' visible='false'>
+      <div><ui:msg>Unmark Private</ui:msg></div>
+    </g:Button>
 
     <g:Button ui:field='submit' styleName='{style.submit}' visible='false'/>
   </g:FlowPanel>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java
index 514b4ad..2080a0e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileAction.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.event.logical.shared.CloseEvent;
 import com.google.gwt.event.logical.shared.CloseHandler;
 import com.google.gwt.user.client.ui.PopupPanel;
@@ -23,6 +24,7 @@
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 
 class AddFileAction {
+  private final Project.NameKey project;
   private final Change.Id changeId;
   private final RevisionInfo revision;
   private final ChangeScreen.Style style;
@@ -33,11 +35,13 @@
   private PopupPanel popup;
 
   AddFileAction(
+      Project.NameKey project,
       Change.Id changeId,
       RevisionInfo revision,
       ChangeScreen.Style style,
       Widget addButton,
       FileTable files) {
+    this.project = project;
     this.changeId = changeId;
     this.revision = revision;
     this.style = style;
@@ -53,7 +57,7 @@
 
     files.unregisterKeys();
     if (addBox == null) {
-      addBox = new AddFileBox(changeId, revision, files);
+      addBox = new AddFileBox(project, changeId, revision, files);
     }
     addBox.clearPath();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java
index 21bb590..cd862d2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/AddFileBox.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.client.ui.RemoteSuggestBox;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.logical.shared.CloseEvent;
@@ -40,6 +41,7 @@
 
   private static final Binder uiBinder = GWT.create(Binder.class);
 
+  private final Project.NameKey project;
   private final Change.Id changeId;
   private final RevisionInfo revision;
   private final FileTable fileTable;
@@ -50,12 +52,13 @@
   @UiField(provided = true)
   RemoteSuggestBox path;
 
-  AddFileBox(Change.Id changeId, RevisionInfo revision, FileTable files) {
+  AddFileBox(Project.NameKey project, Change.Id changeId, RevisionInfo revision, FileTable files) {
+    this.project = project;
     this.changeId = changeId;
     this.revision = revision;
     this.fileTable = files;
 
-    path = new RemoteSuggestBox(new PathSuggestOracle(changeId, revision));
+    path = new RemoteSuggestBox(new PathSuggestOracle(project, changeId, revision));
     path.addSelectionHandler(
         new SelectionHandler<String>() {
           @Override
@@ -90,7 +93,8 @@
 
   private void open(String path) {
     hide();
-    Gerrit.display(Dispatcher.toEditScreen(new PatchSet.Id(changeId, revision._number()), path));
+    Gerrit.display(
+        Dispatcher.toEditScreen(project, new PatchSet.Id(changeId, revision._number()), path));
   }
 
   @UiHandler("cancel")
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
index 7256497..a376782 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.client.ui.RemoteSuggestBox;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -62,6 +63,7 @@
 
   private AssigneeSuggestOracle assigneeSuggestOracle;
   private Change.Id changeId;
+  private Project.NameKey project;
   private boolean canEdit;
   private AccountInfo currentAssignee;
 
@@ -98,6 +100,7 @@
 
   void set(ChangeInfo info) {
     this.changeId = info.legacyId();
+    this.project = info.projectNameKey();
     this.canEdit = info.hasActions() && info.actions().containsKey("assignee");
     assigneeSuggestOracle.setChange(info);
     setAssignee(info.assignee());
@@ -141,9 +144,10 @@
     onCloseForm();
   }
 
-  private void editAssignee(final String assignee) {
+  private void editAssignee(String assignee) {
     if (assignee.trim().isEmpty()) {
       ChangeApi.deleteAssignee(
+          project.get(),
           changeId.get(),
           new GerritCallback<AccountInfo>() {
             @Override
@@ -167,6 +171,7 @@
           });
     } else {
       ChangeApi.setAssignee(
+          project.get(),
           changeId.get(),
           assignee,
           new GerritCallback<AccountInfo>() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
index 1be60cc..0bc74e4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
@@ -19,31 +19,32 @@
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.ui.Button;
 
 public class ChangeActions {
 
-  static void publish(Change.Id id, String revision, Button... draftButtons) {
-    ChangeApi.publish(id.get(), revision, cs(id, draftButtons));
+  static void delete(Project.NameKey project, Change.Id id, Button... draftButtons) {
+    ChangeApi.deleteChange(project.get(), id.get(), mine(draftButtons));
   }
 
-  static void delete(Change.Id id, String revision, Button... draftButtons) {
-    ChangeApi.deleteRevision(id.get(), revision, cs(id, draftButtons));
+  static void markPrivate(Project.NameKey project, Change.Id id, Button... draftButtons) {
+    ChangeApi.markPrivate(project.get(), id.get(), cs(project, id, draftButtons));
   }
 
-  static void delete(Change.Id id, Button... draftButtons) {
-    ChangeApi.deleteChange(id.get(), mine(draftButtons));
+  static void unmarkPrivate(Project.NameKey project, Change.Id id, Button... draftButtons) {
+    ChangeApi.unmarkPrivate(project.get(), id.get(), cs(project, id, draftButtons));
   }
 
   public static GerritCallback<JavaScriptObject> cs(
-      final Change.Id id, final Button... draftButtons) {
+      Project.NameKey project, final Change.Id id, Button... draftButtons) {
     setEnabled(false, draftButtons);
     return new GerritCallback<JavaScriptObject>() {
       @Override
       public void onSuccess(JavaScriptObject result) {
-        Gerrit.display(PageLinks.toChange(id));
+        Gerrit.display(PageLinks.toChange(project, id));
       }
 
       @Override
@@ -51,7 +52,7 @@
         setEnabled(true, draftButtons);
         if (SubmitFailureDialog.isConflict(err)) {
           new SubmitFailureDialog(err.getMessage()).center();
-          Gerrit.display(PageLinks.toChange(id));
+          Gerrit.display(PageLinks.toChange(project, id));
         } else {
           super.onFailure(err);
         }
@@ -59,7 +60,7 @@
     };
   }
 
-  private static AsyncCallback<JavaScriptObject> mine(final Button... draftButtons) {
+  private static AsyncCallback<JavaScriptObject> mine(Button... draftButtons) {
     setEnabled(false, draftButtons);
     return new GerritCallback<JavaScriptObject>() {
       @Override
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 bd211b7..ed67846 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
@@ -43,8 +43,6 @@
 
   String author();
 
-  String draft();
-
   String notAvailable();
 
   String relatedChanges();
@@ -78,6 +76,4 @@
   String deleteChangeEdit();
 
   String deleteChange();
-
-  String deleteDraftRevision();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
index dd4760d..9000149 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeConstants.properties
@@ -13,7 +13,6 @@
 commit = Commit
 date = Date
 author = Author / Committer
-draft = (DRAFT)
 
 notAvailable = N/A
 relatedChanges = Related Changes
@@ -35,4 +34,3 @@
   \n\
   All changes made in the edit revision will be lost.
 deleteChange = Delete Change?
-deleteDraftRevision = Delete Draft Revision?
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 8b699da..8db2da2 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
@@ -57,12 +57,14 @@
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.client.ui.Screen;
 import com.google.gerrit.client.ui.UserActivityMonitor;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 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.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
@@ -159,6 +161,7 @@
   }
 
   private final Change.Id changeId;
+  @Nullable private Project.NameKey project;
   private DiffObject base;
   private String revision;
   private ChangeInfo changeInfo;
@@ -198,6 +201,8 @@
   @UiField InlineLabel uploaderName;
 
   @UiField Element statusText;
+  @UiField Element privateText;
+  @UiField Element wipText;
   @UiField Image projectSettings;
   @UiField AnchorElement projectSettingsLink;
   @UiField InlineHyperlink projectDashboard;
@@ -229,8 +234,6 @@
   @UiField Button publishEdit;
   @UiField Button rebaseEdit;
   @UiField Button deleteEdit;
-  @UiField Button publish;
-  @UiField Button deleteRevision;
   @UiField Button openAll;
   @UiField Button editMode;
   @UiField Button reviewMode;
@@ -252,20 +255,26 @@
   private RenameFileAction renameFileAction;
 
   public ChangeScreen(
+      @Nullable Project.NameKey project,
       Change.Id changeId,
       DiffObject base,
       String revision,
       boolean openReplyBox,
       FileTable.Mode mode) {
+    this.project = project;
     this.changeId = changeId;
     this.base = base;
     this.revision = normalize(revision);
     this.openReplyBox = openReplyBox;
     this.fileTableMode = mode;
-    this.lc = new LocalComments(changeId);
+    this.lc = new LocalComments(project, changeId);
     add(uiBinder.createAndBindUi(this));
   }
 
+  public Project.NameKey getProject() {
+    return project;
+  }
+
   PatchSet.Id getPatchSetId() {
     return new PatchSet.Id(changeInfo.legacyId(), changeInfo.revisions().get(revision)._number());
   }
@@ -289,6 +298,7 @@
                 public void onFailure(Throwable caught) {}
               }));
       ChangeApi.editWithFiles(
+          Project.NameKey.asStringOrNull(project),
           changeId.get(),
           group.add(
               new AsyncCallback<EditInfo>() {
@@ -306,10 +316,18 @@
         group.addFinal(
             new GerritCallback<ChangeInfo>() {
               @Override
-              public void onSuccess(final ChangeInfo info) {
+              public void onSuccess(ChangeInfo info) {
                 info.init();
-                addExtensionPoints(info, initCurrentRevision(info));
+                if (project == null) {
+                  // Update Project when the first API call succeeded if it wasn't already present.
+                  // This is the case when the user used a URL that doesn't include the project.
+                  // Setting it here will rewrite the URL token to include the project (visible to
+                  // the user) and all future API calls made from the change screen will use
+                  // project/+/changeId to identify the change.
+                  project = info.projectNameKey();
+                }
 
+                initCurrentRevision(info);
                 final RevisionInfo rev = info.revision(revision);
                 CallbackGroup group = new CallbackGroup();
                 loadCommit(rev, group);
@@ -378,7 +396,7 @@
     return resolveRevisionToDisplay(info);
   }
 
-  private void addExtensionPoints(ChangeInfo change, RevisionInfo rev) {
+  private void addExtensionPoints(ChangeInfo change, RevisionInfo rev, Entry result) {
     addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER, headerExtension, change, rev);
     addExtensionPoint(
         GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS,
@@ -391,7 +409,12 @@
         change,
         rev);
     addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK, changeExtension, change, rev);
+        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
+        changeExtension,
+        change,
+        rev,
+        result.getExtensionPanelNames(
+            GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK.toString()));
     addExtensionPoint(
         GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
         relatedExtension,
@@ -407,19 +430,28 @@
   }
 
   private void addExtensionPoint(
-      GerritUiExtensionPoint extensionPoint, Panel p, ChangeInfo change, RevisionInfo rev) {
-    ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint);
+      GerritUiExtensionPoint extensionPoint,
+      Panel p,
+      ChangeInfo change,
+      RevisionInfo rev,
+      List<String> panelNames) {
+    ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint, panelNames);
     extensionPanel.putObject(GerritUiExtensionPoint.Key.CHANGE_INFO, change);
     extensionPanel.putObject(GerritUiExtensionPoint.Key.REVISION_INFO, rev);
     p.add(extensionPanel);
   }
 
+  private void addExtensionPoint(
+      GerritUiExtensionPoint extensionPoint, Panel p, ChangeInfo change, RevisionInfo rev) {
+    addExtensionPoint(extensionPoint, p, change, rev, Collections.emptyList());
+  }
+
   private boolean enableSignedPush() {
     return Gerrit.info().receive().enableSignedPush();
   }
 
   void loadChangeInfo(boolean fg, AsyncCallback<ChangeInfo> cb) {
-    RestApi call = ChangeApi.detail(changeId.get());
+    RestApi call = ChangeApi.detail(Project.NameKey.asStringOrNull(project), changeId.get());
     EnumSet<ListChangesOption> opts =
         EnumSet.of(ListChangesOption.ALL_REVISIONS, ListChangesOption.CHANGE_ACTIONS);
     if (enableSignedPush()) {
@@ -433,7 +465,7 @@
   }
 
   void loadRevisionInfo() {
-    RestApi call = ChangeApi.actions(changeId.get(), revision);
+    RestApi call = ChangeApi.actions(getProject().get(), changeId.get(), revision);
     call.background();
     call.get(
         new GerritCallback<NativeMap<ActionInfo>>() {
@@ -502,6 +534,7 @@
         if (0 <= i + offset && i + offset < revisions.length()) {
           Gerrit.display(
               PageLinks.toChange(
+                  project,
                   new PatchSet.Id(changeInfo.legacyId(), revisions.get(i + offset)._number())));
           return;
         }
@@ -512,7 +545,9 @@
 
   private void initIncludedInAction(ChangeInfo info) {
     if (info.status() == Status.MERGED) {
-      includedInAction = new IncludedInAction(info.legacyId(), style, headerLine, includedIn);
+      includedInAction =
+          new IncludedInAction(
+              info.projectNameKey(), info.legacyId(), style, headerLine, includedIn);
       includedIn.setVisible(true);
     }
   }
@@ -525,8 +560,7 @@
     }
   }
 
-  private void initRevisionsAction(
-      ChangeInfo info, String revision, NativeMap<ActionInfo> actions) {
+  private void initRevisionsAction(ChangeInfo info, String revision) {
     int currentPatchSet;
     if (info.currentRevision() != null && info.revisions().containsKey(info.currentRevision())) {
       currentPatchSet = info.revision(info.currentRevision())._number();
@@ -552,26 +586,15 @@
     patchSetsText.setInnerText(Resources.M.patchSets(currentlyViewedPatchSet, currentPatchSet));
     updatePatchSetsTextStyle(isPatchSetCurrent);
     patchSetsAction =
-        new PatchSetsAction(info.legacyId(), revision, edit, style, headerLine, patchSets);
-
-    RevisionInfo revInfo = info.revision(revision);
-    if (revInfo.draft()) {
-      if (actions.containsKey("publish")) {
-        publish.setVisible(true);
-        publish.setTitle(actions.get("publish").title());
-      }
-      if (actions.containsKey("/")) {
-        deleteRevision.setVisible(true);
-        deleteRevision.setTitle(actions.get("/").title());
-      }
-    }
+        new PatchSetsAction(
+            info.projectNameKey(), info.legacyId(), revision, edit, style, headerLine, patchSets);
   }
 
   private void initDownloadAction(ChangeInfo info, String revision) {
     downloadAction = new DownloadAction(info, revision, style, headerLine, download);
   }
 
-  private void initProjectLinks(final ChangeInfo info) {
+  private void initProjectLinks(ChangeInfo info) {
     projectSettingsLink.setHref("#" + PageLinks.toProject(info.projectNameKey()));
     projectSettings.addDomHandler(
         new ClickHandler() {
@@ -608,11 +631,14 @@
           renameFile.setVisible(!editMode.isVisible());
           reviewMode.setVisible(!editMode.isVisible());
           addFileAction =
-              new AddFileAction(changeId, info.revision(revision), style, addFile, files);
+              new AddFileAction(
+                  info.projectNameKey(), changeId, info.revision(revision), style, addFile, files);
           deleteFileAction =
-              new DeleteFileAction(changeId, info.revision(revision), style, addFile);
+              new DeleteFileAction(
+                  info.projectNameKey(), changeId, info.revision(revision), style, addFile);
           renameFileAction =
-              new RenameFileAction(changeId, info.revision(revision), style, addFile);
+              new RenameFileAction(
+                  info.projectNameKey(), changeId, info.revision(revision), style, addFile);
         } else {
           editMode.setVisible(false);
           addFile.setVisible(false);
@@ -646,30 +672,18 @@
 
   @UiHandler("publishEdit")
   void onPublishEdit(@SuppressWarnings("unused") ClickEvent e) {
-    EditActions.publishEdit(changeId, publishEdit, rebaseEdit, deleteEdit);
+    EditActions.publishEdit(getProject(), changeId, publishEdit, rebaseEdit, deleteEdit);
   }
 
   @UiHandler("rebaseEdit")
   void onRebaseEdit(@SuppressWarnings("unused") ClickEvent e) {
-    EditActions.rebaseEdit(changeId, publishEdit, rebaseEdit, deleteEdit);
+    EditActions.rebaseEdit(getProject(), changeId, publishEdit, rebaseEdit, deleteEdit);
   }
 
   @UiHandler("deleteEdit")
   void onDeleteEdit(@SuppressWarnings("unused") ClickEvent e) {
     if (Window.confirm(Resources.C.deleteChangeEdit())) {
-      EditActions.deleteEdit(changeId, publishEdit, rebaseEdit, deleteEdit);
-    }
-  }
-
-  @UiHandler("publish")
-  void onPublish(@SuppressWarnings("unused") ClickEvent e) {
-    ChangeActions.publish(changeId, revision, publish, deleteRevision);
-  }
-
-  @UiHandler("deleteRevision")
-  void onDeleteRevision(@SuppressWarnings("unused") ClickEvent e) {
-    if (Window.confirm(Resources.C.deleteDraftRevision())) {
-      ChangeActions.delete(changeId, revision, publish, deleteRevision);
+      EditActions.deleteEdit(getProject(), changeId, publishEdit, rebaseEdit, deleteEdit);
     }
   }
 
@@ -689,7 +703,7 @@
         new KeyCommand(0, 'R', Util.C.keyReloadChange()) {
           @Override
           public void onKeyPress(KeyPressEvent event) {
-            Gerrit.display(PageLinks.toChange(changeId));
+            Gerrit.display(PageLinks.toChange(project, changeId));
           }
         });
     keysNavigation.add(
@@ -799,21 +813,20 @@
   }
 
   private void scrollToPath(String token) {
-    int s = token.indexOf('/');
+    ProjectChangeId cId;
     try {
-      String c = token.substring(0, s);
-      int editIndex = c.indexOf(",edit");
-      if (editIndex > 0) {
-        c = c.substring(0, editIndex);
-      }
-      if (s < 0 || !changeId.equals(Change.Id.parse(c))) {
-        return; // Unrelated URL, do not scroll.
-      }
+      cId = ProjectChangeId.create(token);
     } catch (IllegalArgumentException e) {
+      // Scrolling is best-effort.
       return;
     }
+    if (!changeId.equals(cId.getChangeId())) {
+      return; // Unrelated URL, do not scroll.
+    }
 
-    s = token.indexOf('/', s + 1);
+    // Extract the start of a file path. The patch set is always contained in the URL and separated
+    // by from the changeId by a forward slash. Example: /c/project/+/123/1/folder/file.txt
+    int s = token.indexOf('/', cId.identifierLength() + 1);
     if (s < 0) {
       return; // URL does not name a file.
     }
@@ -858,7 +871,7 @@
   @UiHandler("permalink")
   void onReload(ClickEvent e) {
     e.preventDefault();
-    Gerrit.display(PageLinks.toChange(changeId));
+    Gerrit.display(PageLinks.toChange(project, changeId));
   }
 
   private void onReply() {
@@ -972,7 +985,7 @@
     }
   }
 
-  private void loadConfigInfo(final ChangeInfo info, DiffObject base) {
+  private void loadConfigInfo(ChangeInfo info, DiffObject base) {
     final RevisionInfo rev = info.revision(revision);
     if (base.isAutoMerge() && !initCurrentRevision(info).isMerge()) {
       Gerrit.display(getToken(), new NotFoundScreen());
@@ -1011,7 +1024,7 @@
     group.done();
   }
 
-  private void loadConfigInfo(final ChangeInfo info, RevisionInfo rev) {
+  private void loadConfigInfo(ChangeInfo info, RevisionInfo rev) {
     if (loaded) {
       return;
     }
@@ -1030,10 +1043,21 @@
             loadRevisionInfo();
           }
         });
+    ConfigInfoCache.get(
+        info.projectNameKey(),
+        new GerritCallback<Entry>() {
+          @Override
+          public void onSuccess(Entry entry) {
+            addExtensionPoints(info, rev, entry);
+          }
+        });
   }
 
   private void updateToken(ChangeInfo info, DiffObject base, RevisionInfo rev) {
-    StringBuilder token = new StringBuilder("/c/").append(info._number()).append("/");
+    StringBuilder token =
+        new StringBuilder("/c/")
+            .append(PageLinks.toChangeId(info.projectNameKey(), info.legacyId()))
+            .append("/");
     if (base.asString() != null) {
       token.append(base.asString()).append("..");
     }
@@ -1067,7 +1091,7 @@
     loadFileList(base, baseRev, rev, myLastReply, group, comments, drafts);
 
     if (Gerrit.isSignedIn() && fileTableMode == FileTable.Mode.REVIEW) {
-      ChangeApi.revision(changeId.get(), rev.name())
+      ChangeApi.revision(getProject().get(), changeId.get(), rev.name())
           .view("files")
           .addParameterTrue("reviewed")
           .get(
@@ -1093,6 +1117,7 @@
       final List<NativeMap<JsArray<CommentInfo>>> comments,
       final List<NativeMap<JsArray<CommentInfo>>> drafts) {
     DiffApi.list(
+        getProject().get(),
         changeId.get(),
         rev.name(),
         baseRev,
@@ -1103,6 +1128,7 @@
                 files.set(
                     base,
                     new PatchSet.Id(changeId, rev._number()),
+                    getProject(),
                     style,
                     reply,
                     fileTableMode,
@@ -1126,7 +1152,7 @@
     final List<NativeMap<JsArray<CommentInfo>>> r = new ArrayList<>(1);
     // TODO(dborowitz): Could eliminate this call by adding an option to include
     // inline comments in the change detail.
-    ChangeApi.comments(changeId.get())
+    ChangeApi.comments(getProject().get(), changeId.get())
         .get(
             group.add(
                 new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
@@ -1165,7 +1191,7 @@
   private List<NativeMap<JsArray<CommentInfo>>> loadDrafts(RevisionInfo rev, CallbackGroup group) {
     final List<NativeMap<JsArray<CommentInfo>>> r = new ArrayList<>(1);
     if (Gerrit.isSignedIn()) {
-      ChangeApi.revision(changeId.get(), rev.name())
+      ChangeApi.revision(getProject().get(), changeId.get(), rev.name())
           .view("drafts")
           .get(
               group.add(
@@ -1184,12 +1210,13 @@
     return r;
   }
 
-  private void loadCommit(final RevisionInfo rev, CallbackGroup group) {
+  private void loadCommit(RevisionInfo rev, CallbackGroup group) {
     if (rev.isEdit() || rev.commit() != null) {
       return;
     }
 
     ChangeApi.commitWithLinks(
+        getProject().get(),
         changeId.get(),
         rev.name(),
         group.add(
@@ -1205,11 +1232,13 @@
   }
 
   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());
+    if (status == Change.Status.NEW && !changeInfo.isWorkInProgress()) {
+      if (canSubmit) {
+        statusText.setInnerText(
+            changeInfo.mergeable() ? Util.C.readyToSubmit() : Util.C.mergeConflict());
+      }
+      setVisible(notMergeable, !changeInfo.mergeable());
     }
-    setVisible(notMergeable, !changeInfo.mergeable());
     submitActionText.setInnerText(com.google.gerrit.client.admin.Util.toLongString(submitType));
   }
 
@@ -1269,10 +1298,7 @@
   }
 
   private boolean isSubmittable(ChangeInfo info) {
-    boolean canSubmit =
-        info.status().isOpen()
-            && revision.equals(info.currentRevision())
-            && !info.revision(revision).draft();
+    boolean canSubmit = info.status().isOpen() && revision.equals(info.currentRevision());
     if (canSubmit && info.status() == Change.Status.NEW) {
       for (String name : info.labels()) {
         LabelInfo label = info.label(name);
@@ -1350,7 +1376,7 @@
     // Properly render revision actions initially while waiting for
     // the callback to populate them correctly.
     NativeMap<ActionInfo> emptyMap = NativeMap.<ActionInfo>create();
-    initRevisionsAction(info, revision, emptyMap);
+    initRevisionsAction(info, revision);
     quickApprove.setVisible(false);
     actions.reloadRevisionActions(emptyMap);
 
@@ -1362,8 +1388,15 @@
       statusText.setInnerText(Util.C.notCurrent());
       labels.setVisible(false);
     } else {
-      Status s = info.revision(revision).draft() ? Status.DRAFT : info.status();
-      statusText.setInnerText(Util.toLongString(s));
+      statusText.setInnerText(Util.toLongString(info.status()));
+    }
+
+    if (info.isPrivate()) {
+      privateText.setInnerText(Util.C.isPrivate());
+    }
+
+    if (info.isWorkInProgress()) {
+      wipText.setInnerText(Util.C.isWorkInProgress());
     }
 
     if (Gerrit.isSignedIn()) {
@@ -1382,7 +1415,7 @@
   }
 
   private void renderRevisionInfo(ChangeInfo info, NativeMap<ActionInfo> actionMap) {
-    initRevisionsAction(info, revision, actionMap);
+    initRevisionsAction(info, revision);
     commit.setParentNotCurrent(
         actionMap.containsKey("rebase") && actionMap.get("rebase").enabled());
     actions.reloadRevisionActions(actionMap);
@@ -1547,7 +1580,7 @@
           new UpdateAvailableBar() {
             @Override
             void onShow() {
-              Gerrit.display(PageLinks.toChange(changeId));
+              Gerrit.display(PageLinks.toChange(project, changeId));
             }
 
             @Override
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 152b157..d629fc2 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
@@ -99,6 +99,13 @@
     .statusText {
       font-weight: bold;
     }
+    .privateText {
+      font-weight: bold;
+    }
+
+    .wipText {
+      font-weight: bold;
+    }
 
     div.popdown {
       display: inline-block;
@@ -376,7 +383,9 @@
           <span class='{style.changeId}'>
             <ui:msg>Change <g:Anchor ui:field='permalink' title='Reload the change (Shortcut: R)'>
               <ui:attribute name='title'/>
-            </g:Anchor> - <span ui:field='statusText' class='{style.statusText}'/></ui:msg>
+            </g:Anchor> - <span ui:field='statusText' class='{style.statusText}'/>
+              <span ui:field='privateText' class='{style.privateText}'/>
+              <span ui:field='wipText' class='{style.wipText}'/></ui:msg>
           </span>
           <g:SimplePanel ui:field='headerExtension' styleName='{style.headerExtension}'/>
         </div>
@@ -406,13 +415,6 @@
           <g:Button ui:field='deleteEdit' styleName='' visible='false'>
             <div><ui:msg>Delete Edit</ui:msg></div>
           </g:Button>
-          <g:Button ui:field='publish'
-              styleName='{style.highlight}' visible='false'>
-            <div><ui:msg>Publish</ui:msg></div>
-          </g:Button>
-          <g:Button ui:field='deleteRevision' styleName='' visible='false'>
-            <div><ui:msg>Delete Revision</ui:msg></div>
-          </g:Button>
           <g:SimplePanel ui:field='headerExtensionMiddle' styleName='{style.headerExtension}'/>
         </div>
       </div>
@@ -524,6 +526,7 @@
               <td ui:field='actionDate'/>
             </tr>
             <tr ui:field='hashtagTableRow'>
+              <th><ui:msg>Hashtags</ui:msg></th>
               <td colspan='2'>
                 <c:Hashtags ui:field='hashtags'/>
               </td>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java
index 5fb0e7b..be011d2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/CherryPickAction.java
@@ -32,11 +32,11 @@
       final Button b,
       final ChangeInfo info,
       final String revision,
-      String project,
+      final Project.NameKey project,
       final String commitMessage) {
     // TODO Replace CherryPickDialog with a nicer looking display.
     b.setEnabled(false);
-    new CherryPickDialog(new Project.NameKey(project)) {
+    new CherryPickDialog(project) {
       {
         sendButton.setText(Util.C.buttonCherryPickChangeSend());
         if (info.status() == Change.Status.MERGED) {
@@ -49,6 +49,7 @@
       @Override
       public void onSend() {
         ChangeApi.cherrypick(
+            info.project(),
             info.legacyId().get(),
             revision,
             getDestinationBranch(),
@@ -58,7 +59,7 @@
               public void onSuccess(ChangeInfo result) {
                 sent = true;
                 hide();
-                Gerrit.display(PageLinks.toChange(result.legacyId()));
+                Gerrit.display(PageLinks.toChange(project, result.legacyId()));
               }
 
               @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java
index 4dcdc6e..9369c18 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileAction.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.event.logical.shared.CloseEvent;
 import com.google.gwt.event.logical.shared.CloseHandler;
 import com.google.gwt.user.client.ui.PopupPanel;
@@ -23,6 +24,7 @@
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 
 class DeleteFileAction {
+  private final Project.NameKey project;
   private final Change.Id changeId;
   private final RevisionInfo revision;
   private final ChangeScreen.Style style;
@@ -32,7 +34,12 @@
   private PopupPanel popup;
 
   DeleteFileAction(
-      Change.Id changeId, RevisionInfo revision, ChangeScreen.Style style, Widget deleteButton) {
+      Project.NameKey project,
+      Change.Id changeId,
+      RevisionInfo revision,
+      ChangeScreen.Style style,
+      Widget deleteButton) {
+    this.project = project;
     this.changeId = changeId;
     this.revision = revision;
     this.style = style;
@@ -46,7 +53,7 @@
     }
 
     if (deleteBox == null) {
-      deleteBox = new DeleteFileBox(changeId, revision);
+      deleteBox = new DeleteFileBox(project, changeId, revision);
     }
     deleteBox.clearPath();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java
index 3edfca2..1885293 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DeleteFileBox.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.client.ui.RemoteSuggestBox;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.logical.shared.CloseEvent;
@@ -42,6 +43,7 @@
 
   private static final Binder uiBinder = GWT.create(Binder.class);
 
+  private final Project.NameKey project;
   private final Change.Id changeId;
 
   @UiField Button delete;
@@ -50,10 +52,11 @@
   @UiField(provided = true)
   RemoteSuggestBox path;
 
-  DeleteFileBox(Change.Id changeId, RevisionInfo revision) {
+  DeleteFileBox(Project.NameKey project, Change.Id changeId, RevisionInfo revision) {
+    this.project = project;
     this.changeId = changeId;
 
-    path = new RemoteSuggestBox(new PathSuggestOracle(changeId, revision));
+    path = new RemoteSuggestBox(new PathSuggestOracle(project, changeId, revision));
     path.addSelectionHandler(
         new SelectionHandler<String>() {
           @Override
@@ -88,12 +91,13 @@
   private void delete(String path) {
     hide();
     ChangeEditApi.delete(
+        project.get(),
         changeId.get(),
         path,
         new AsyncCallback<VoidResult>() {
           @Override
           public void onSuccess(VoidResult result) {
-            Gerrit.display(PageLinks.toChangeInEditMode(changeId));
+            Gerrit.display(PageLinks.toChangeInEditMode(project, changeId));
           }
 
           @Override
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 6c2964d..547f3d5 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
@@ -78,7 +78,7 @@
   protected void onLoad() {
     if (fetch == null) {
       if (psId.get() == 0) {
-        ChangeApi.editWithCommands(change.legacyId().get())
+        ChangeApi.editWithCommands(change.project(), change.legacyId().get())
             .get(
                 new AsyncCallback<EditInfo>() {
                   @Override
@@ -91,7 +91,7 @@
                   public void onFailure(Throwable caught) {}
                 });
       } else {
-        RestApi call = ChangeApi.detail(change.legacyId().get());
+        RestApi call = ChangeApi.detail(change.project(), change.legacyId().get());
         ChangeList.addOptions(
             call,
             EnumSet.of(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
index 97abddb..f075c16 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/EditActions.java
@@ -19,30 +19,31 @@
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.user.client.ui.Button;
 
 public class EditActions {
 
-  static void deleteEdit(Change.Id id, Button... editButtons) {
-    ChangeApi.deleteEdit(id.get(), cs(id, editButtons));
+  static void deleteEdit(Project.NameKey project, Change.Id id, Button... editButtons) {
+    ChangeApi.deleteEdit(project.get(), id.get(), cs(project, id, editButtons));
   }
 
-  static void publishEdit(Change.Id id, Button... editButtons) {
-    ChangeApi.publishEdit(id.get(), cs(id, editButtons));
+  static void publishEdit(Project.NameKey project, Change.Id id, Button... editButtons) {
+    ChangeApi.publishEdit(project.get(), id.get(), cs(project, id, editButtons));
   }
 
-  static void rebaseEdit(Change.Id id, Button... editButtons) {
-    ChangeApi.rebaseEdit(id.get(), cs(id, editButtons));
+  static void rebaseEdit(Project.NameKey project, Change.Id id, Button... editButtons) {
+    ChangeApi.rebaseEdit(project.get(), id.get(), cs(project, id, editButtons));
   }
 
   public static GerritCallback<JavaScriptObject> cs(
-      final Change.Id id, final Button... editButtons) {
+      Project.NameKey project, final Change.Id id, Button... editButtons) {
     setEnabled(false, editButtons);
     return new GerritCallback<JavaScriptObject>() {
       @Override
       public void onSuccess(JavaScriptObject result) {
-        Gerrit.display(PageLinks.toChange(id));
+        Gerrit.display(PageLinks.toChange(project, id));
       }
 
       @Override
@@ -50,7 +51,7 @@
         setEnabled(true, editButtons);
         if (SubmitFailureDialog.isConflict(err)) {
           new SubmitFailureDialog(err.getMessage()).center();
-          Gerrit.display(PageLinks.toChange(id));
+          Gerrit.display(PageLinks.toChange(project, id));
         } else {
           super.onFailure(err);
         }
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 0e30a8c..083c824 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
@@ -19,6 +19,7 @@
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.uibinder.client.UiBinder;
 import com.google.gwt.uibinder.client.UiField;
@@ -36,17 +37,21 @@
   @UiField FlowPanel comments;
 
   FileComments(
-      CommentLinkProcessor clp, PatchSet.Id defaultPs, String title, List<CommentInfo> list) {
+      CommentLinkProcessor clp,
+      Project.NameKey project,
+      PatchSet.Id defaultPs,
+      String title,
+      List<CommentInfo> list) {
     initWidget(uiBinder.createAndBindUi(this));
 
-    path.setTargetHistoryToken(url(defaultPs, list.get(0)));
+    path.setTargetHistoryToken(url(project, defaultPs, list.get(0)));
     path.setText(title);
     for (CommentInfo c : list) {
-      comments.add(new LineComment(clp, defaultPs, c));
+      comments.add(new LineComment(clp, project, defaultPs, c));
     }
   }
 
-  private static String url(PatchSet.Id ps, CommentInfo info) {
-    return Dispatcher.toPatch(null, ps, info.path());
+  private static String url(Project.NameKey project, PatchSet.Id ps, CommentInfo info) {
+    return Dispatcher.toPatch(project, 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 65e3dc0..30554b6 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
@@ -41,6 +41,7 @@
 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.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
@@ -199,6 +200,7 @@
 
   private DiffObject base;
   private PatchSet.Id curr;
+  private Project.NameKey project;
   private MyTable table;
   private boolean register;
   private JsArrayString reviewed;
@@ -217,12 +219,14 @@
   public void set(
       DiffObject base,
       PatchSet.Id curr,
+      Project.NameKey project,
       ChangeScreen.Style style,
       Widget replyButton,
       Mode mode,
       boolean editExists) {
     this.base = base;
     this.curr = curr;
+    this.project = project;
     this.style = style;
     this.replyButton = replyButton;
     this.mode = mode;
@@ -318,10 +322,10 @@
 
   private String url(FileInfo info) {
     return info.binary()
-        ? Dispatcher.toUnified(base, curr, info.path())
+        ? Dispatcher.toUnified(project, base, curr, info.path())
         : mode == Mode.REVIEW
-            ? Dispatcher.toPatch(base, curr, info.path())
-            : Dispatcher.toEditScreen(curr, info.path());
+            ? Dispatcher.toPatch(project, base, curr, info.path())
+            : Dispatcher.toEditScreen(project, curr, info.path());
   }
 
   private final class MyTable extends NavigationTable<FileInfo> {
@@ -364,12 +368,13 @@
     void onDelete(int idx) {
       String path = list.get(idx).path();
       ChangeEditApi.delete(
+          project.get(),
           curr.getParentKey().get(),
           path,
           new AsyncCallback<VoidResult>() {
             @Override
             public void onSuccess(VoidResult result) {
-              Gerrit.display(PageLinks.toChangeInEditMode(curr.getParentKey()));
+              Gerrit.display(PageLinks.toChangeInEditMode(project, curr.getParentKey()));
             }
 
             @Override
@@ -380,12 +385,13 @@
     void onRestore(int idx) {
       String path = list.get(idx).path();
       ChangeEditApi.restore(
+          project.get(),
           curr.getParentKey().get(),
           path,
           new AsyncCallback<VoidResult>() {
             @Override
             public void onSuccess(VoidResult result) {
-              Gerrit.display(PageLinks.toChangeInEditMode(curr.getParentKey()));
+              Gerrit.display(PageLinks.toChangeInEditMode(project, curr.getParentKey()));
             }
 
             @Override
@@ -398,7 +404,8 @@
     }
 
     private void setReviewed(FileInfo info, boolean r) {
-      RestApi api = ChangeApi.revision(curr).view("files").id(info.path()).view("reviewed");
+      RestApi api =
+          ChangeApi.revision(project.get(), curr).view("files").id(info.path()).view("reviewed");
       if (r) {
         api.put(CallbackGroup.<ReviewInfo>emptyCallback());
       } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java
index 5c7472c..a4c90b8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FollowUpAction.java
@@ -46,7 +46,7 @@
         new GerritCallback<ChangeInfo>() {
           @Override
           public void onSuccess(ChangeInfo result) {
-            Gerrit.display(PageLinks.toChange(result.legacyId()));
+            Gerrit.display(PageLinks.toChange(result.projectNameKey(), result.legacyId()));
             hide();
           }
         });
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 192be34..1044828 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
@@ -21,6 +21,7 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArrayString;
@@ -73,14 +74,14 @@
     if (hashtags != null) {
       final ChangeScreen screen = ChangeScreen.get(event);
       final PatchSet.Id psId = screen.getPatchSetId();
-      ChangeApi.hashtags(psId.getParentKey().get())
+      ChangeApi.hashtags(screen.getProject().get(), psId.getParentKey().get())
           .post(
               PostInput.create(null, hashtags),
               new GerritCallback<JavaScriptObject>() {
                 @Override
                 public void onSuccess(JavaScriptObject result) {
                   if (screen.isCurrentView()) {
-                    Gerrit.display(PageLinks.toChange(psId));
+                    Gerrit.display(PageLinks.toChange(screen.getProject(), psId));
                   }
                 }
               });
@@ -107,6 +108,7 @@
 
   private ChangeScreen.Style style;
   private Change.Id changeId;
+  private Project.NameKey project;
 
   public Hashtags() {
 
@@ -141,6 +143,7 @@
 
   void set(ChangeInfo info, String revision) {
     psId = new PatchSet.Id(info.legacyId(), info.revisions().get(revision)._number());
+    project = info.projectNameKey();
 
     canEdit = info.hasActions() && info.actions().containsKey("hashtags");
     this.changeId = info.legacyId();
@@ -218,14 +221,15 @@
     }
   }
 
-  private void addHashtag(final String hashtags) {
-    ChangeApi.hashtags(changeId.get())
+  private void addHashtag(String hashtags) {
+    ChangeApi.hashtags(project.get(), changeId.get())
         .post(
             PostInput.create(hashtags, null),
             new GerritCallback<JsArrayString>() {
               @Override
               public void onSuccess(JsArrayString result) {
-                Gerrit.display(PageLinks.toChange(psId.getParentKey(), String.valueOf(psId.get())));
+                Gerrit.display(
+                    PageLinks.toChange(project, psId.getParentKey(), String.valueOf(psId.get())));
               }
 
               @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java
index e221f54..55e021f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/History.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.Widget;
@@ -35,6 +36,7 @@
   private CommentLinkProcessor clp;
   private ReplyAction replyAction;
   private Change.Id changeId;
+  private Project.NameKey project;
 
   private final Map<Integer, List<CommentInfo>> byAuthor = new HashMap<>();
 
@@ -42,6 +44,7 @@
     this.clp = clp;
     this.replyAction = ra;
     this.changeId = id;
+    this.project = info.projectNameKey();
 
     JsArray<MessageInfo> messages = info.messages();
     if (messages != null) {
@@ -80,6 +83,10 @@
     return changeId;
   }
 
+  Project.NameKey getProject() {
+    return project;
+  }
+
   void replyTo(MessageInfo info) {
     replyAction.onReply(info);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java
index 00b6c3c..5557f90 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInAction.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.change;
 
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.Widget;
 
@@ -22,9 +23,13 @@
   private final IncludedInBox includedInBox;
 
   IncludedInAction(
-      Change.Id changeId, ChangeScreen.Style style, UIObject relativeTo, Widget includedInButton) {
+      Project.NameKey project,
+      Change.Id changeId,
+      ChangeScreen.Style style,
+      UIObject relativeTo,
+      Widget includedInButton) {
     super(style, relativeTo, includedInButton);
-    this.includedInBox = new IncludedInBox(changeId);
+    this.includedInBox = new IncludedInBox(project, changeId);
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java
index 0f121cc..9751f54 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/IncludedInBox.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.info.ChangeInfo.IncludedInInfo;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.dom.client.Document;
@@ -42,6 +43,7 @@
     String includedInElement();
   }
 
+  private final Project.NameKey project;
   private final Change.Id changeId;
   private boolean loaded;
 
@@ -50,7 +52,8 @@
   @UiField Element branches;
   @UiField Element tags;
 
-  IncludedInBox(Change.Id changeId) {
+  IncludedInBox(Project.NameKey project, Change.Id changeId) {
+    this.project = project;
     this.changeId = changeId;
     initWidget(uiBinder.createAndBindUi(this));
   }
@@ -59,6 +62,7 @@
   protected void onLoad() {
     if (!loaded) {
       ChangeApi.includedIn(
+          project.get(),
           changeId.get(),
           new AsyncCallback<IncludedInInfo>() {
             @Override
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 fc34aeb..1f4820f 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
@@ -72,13 +72,13 @@
     if (user != null) {
       final ChangeScreen screen = ChangeScreen.get(event);
       final Change.Id changeId = screen.getPatchSetId().getParentKey();
-      ChangeApi.reviewer(changeId.get(), user)
+      ChangeApi.reviewer(screen.getProject().get(), changeId.get(), user)
           .delete(
               new GerritCallback<JavaScriptObject>() {
                 @Override
                 public void onSuccess(JavaScriptObject result) {
                   if (screen.isCurrentView()) {
-                    Gerrit.display(PageLinks.toChange(changeId));
+                    Gerrit.display(PageLinks.toChange(screen.getProject(), changeId));
                   }
                 }
               });
@@ -91,13 +91,13 @@
     if (user != null && vote != null) {
       final ChangeScreen screen = ChangeScreen.get(event);
       final Change.Id changeId = screen.getPatchSetId().getParentKey();
-      ChangeApi.vote(changeId.get(), user, vote)
+      ChangeApi.vote(screen.getProject().get(), changeId.get(), user, vote)
           .delete(
               new GerritCallback<JavaScriptObject>() {
                 @Override
                 public void onSuccess(JavaScriptObject result) {
                   if (screen.isCurrentView()) {
-                    Gerrit.display(PageLinks.toChange(changeId));
+                    Gerrit.display(PageLinks.toChange(screen.getProject(), changeId));
                   }
                 }
               });
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 a1ad7c2..5a0cc59 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
@@ -22,6 +22,7 @@
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.uibinder.client.UiBinder;
@@ -43,7 +44,8 @@
   @UiField InlineHyperlink line;
   @UiField Element message;
 
-  LineComment(CommentLinkProcessor clp, PatchSet.Id defaultPs, CommentInfo info) {
+  LineComment(
+      CommentLinkProcessor clp, Project.NameKey project, PatchSet.Id defaultPs, CommentInfo info) {
     initWidget(uiBinder.createAndBindUi(this));
 
     PatchSet.Id ps;
@@ -70,7 +72,7 @@
       fileLoc.removeFromParent();
       fileLoc = null;
 
-      line.setTargetHistoryToken(url(ps, info));
+      line.setTargetHistoryToken(url(project, ps, info));
       line.setText(Integer.toString(info.line()));
 
     } else {
@@ -86,8 +88,9 @@
     }
   }
 
-  private static String url(PatchSet.Id ps, CommentInfo info) {
+  private static String url(Project.NameKey project, PatchSet.Id ps, CommentInfo info) {
     return Dispatcher.toPatch(
+        project,
         null,
         ps,
         info.path(),
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
index 689aa2a..44652cf 100644
--- 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
@@ -19,24 +19,30 @@
 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.common.Nullable;
+import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 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 {
+  @Nullable private final Project.NameKey project;
   private final Change.Id changeId;
   private final PatchSet.Id psId;
   private final StorageBackend storage;
 
   private static class InlineComment {
+    @Nullable final Project.NameKey project;
     final PatchSet.Id psId;
     final CommentInfo commentInfo;
 
-    InlineComment(PatchSet.Id psId, CommentInfo commentInfo) {
+    InlineComment(@Nullable Project.NameKey project, PatchSet.Id psId, CommentInfo commentInfo) {
+      this.project = project;
       this.psId = psId;
       this.commentInfo = commentInfo;
     }
@@ -87,13 +93,15 @@
     }
   }
 
-  public LocalComments(Change.Id changeId) {
+  public LocalComments(@Nullable Project.NameKey project, Change.Id changeId) {
+    this.project = project;
     this.changeId = changeId;
     this.psId = null;
     this.storage = new StorageBackend();
   }
 
-  public LocalComments(PatchSet.Id psId) {
+  public LocalComments(@Nullable Project.NameKey project, PatchSet.Id psId) {
+    this.project = project;
     this.changeId = psId.getParentKey();
     this.psId = psId;
     this.storage = new StorageBackend();
@@ -120,16 +128,17 @@
   }
 
   private String getReplyCommentName() {
-    return "savedReplyComment-" + changeId.toString();
+    return "savedReplyComment~" + PageLinks.toChangeId(project, changeId);
   }
 
   public static void saveInlineComments() {
     final StorageBackend storage = new StorageBackend();
-    for (final String cookie : storage.getKeys()) {
+    for (String cookie : storage.getKeys()) {
       if (isInlineComment(cookie)) {
         InlineComment input = getInlineComment(cookie);
         if (input.commentInfo.id() == null) {
           CommentApi.createDraft(
+              Project.NameKey.asStringOrNull(input.project),
               input.psId,
               input.commentInfo,
               new GerritCallback<CommentInfo>() {
@@ -140,6 +149,7 @@
               });
         } else {
           CommentApi.updateDraft(
+              Project.NameKey.asStringOrNull(input.project),
               input.psId,
               input.commentInfo.id(),
               input.commentInfo,
@@ -184,9 +194,9 @@
   }
 
   private static boolean isInlineComment(String key) {
-    return key.startsWith("patchCommentEdit-")
-        || key.startsWith("patchReply-")
-        || key.startsWith("patchComment-");
+    return key.startsWith("patchCommentEdit~")
+        || key.startsWith("patchReply~")
+        || key.startsWith("patchComment~");
   }
 
   private static InlineComment getInlineComment(String key) {
@@ -196,13 +206,13 @@
     CommentRange range;
     StorageBackend storage = new StorageBackend();
 
-    String[] elements = key.split("-");
+    String[] elements = key.split("~");
     int offset = 1;
-    if (key.startsWith("patchReply-") || key.startsWith("patchCommentEdit-")) {
+    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]));
+    ProjectChangeId id = ProjectChangeId.create(elements[offset + 0]);
+    PatchSet.Id psId = new PatchSet.Id(id.getChangeId(), Integer.parseInt(elements[offset + 1]));
     path = atob(elements[offset + 2]);
     side = (Side.PARENT.toString().equals(elements[offset + 3])) ? Side.PARENT : Side.REVISION;
     range = null;
@@ -222,12 +232,12 @@
     }
     CommentInfo info = CommentInfo.create(path, side, line, range, false);
     info.message(storage.getItem(key));
-    if (key.startsWith("patchReply-")) {
+    if (key.startsWith("patchReply~")) {
       info.inReplyTo(elements[1]);
-    } else if (key.startsWith("patchCommentEdit-")) {
+    } else if (key.startsWith("patchCommentEdit~")) {
       info.id(elements[1]);
     }
-    InlineComment inlineComment = new InlineComment(psId, info);
+    InlineComment inlineComment = new InlineComment(id.getProject(), psId, info);
     return inlineComment;
   }
 
@@ -235,21 +245,22 @@
     if (psId == null) {
       return null;
     }
-    String result = "patchComment-";
+    String result = "patchComment~";
     if (comment.id() != null) {
-      result = "patchCommentEdit-" + comment.id() + "-";
+      result = "patchCommentEdit~" + comment.id() + "~";
     } else if (comment.inReplyTo() != null) {
-      result = "patchReply-" + comment.inReplyTo() + "-";
+      result = "patchReply~" + comment.inReplyTo() + "~";
     }
-    result +=
-        changeId + "-" + psId.getId() + "-" + btoa(comment.path()) + "-" + comment.side() + "-";
+
+    result += PageLinks.toChangeId(project, changeId);
+    result += "~" + psId.getId() + "~" + btoa(comment.path()) + "~" + comment.side() + "~";
     if (comment.hasRange()) {
       result +=
           "R"
               + comment.range().startLine()
               + ","
               + comment.range().startCharacter()
-              + "-"
+              + "~"
               + comment.range().endLine()
               + ","
               + comment.range().endCharacter();
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 a8fe2f0..cadaf97 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
@@ -173,14 +173,14 @@
     TreeMap<String, List<CommentInfo>> m = byPath(list);
     List<CommentInfo> l = m.remove(Patch.COMMIT_MSG);
     if (l != null) {
-      comments.add(new FileComments(clp, ps, Util.C.commitMessage(), l));
+      comments.add(new FileComments(clp, history.getProject(), ps, Util.C.commitMessage(), l));
     }
     l = m.remove(Patch.MERGE_LIST);
     if (l != null) {
-      comments.add(new FileComments(clp, ps, Util.C.mergeList(), l));
+      comments.add(new FileComments(clp, history.getProject(), ps, Util.C.mergeList(), l));
     }
     for (Map.Entry<String, List<CommentInfo>> e : m.entrySet()) {
-      comments.add(new FileComments(clp, ps, e.getKey(), e.getValue()));
+      comments.add(new FileComments(clp, history.getProject(), ps, e.getKey(), e.getValue()));
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/MoveAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/MoveAction.java
new file mode 100644
index 0000000..e3e9525
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/MoveAction.java
@@ -0,0 +1,67 @@
+// 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.client.change;
+
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.Util;
+import com.google.gerrit.client.info.ChangeInfo;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.ui.MoveDialog;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gwt.event.logical.shared.CloseEvent;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.PopupPanel;
+
+class MoveAction {
+  static void call(Button b, ChangeInfo info, Project.NameKey project) {
+    b.setEnabled(false);
+    new MoveDialog(project) {
+      {
+        sendButton.setText(Util.C.moveChangeSend());
+      }
+
+      @Override
+      public void onSend() {
+        ChangeApi.move(
+            info.project(),
+            info.legacyId().get(),
+            getDestinationBranch(),
+            getMessageText(),
+            new GerritCallback<ChangeInfo>() {
+              @Override
+              public void onSuccess(ChangeInfo result) {
+                sent = true;
+                hide();
+                Gerrit.display(PageLinks.toChange(project, result.legacyId()));
+              }
+
+              @Override
+              public void onFailure(Throwable caught) {
+                enableButtons(true);
+                super.onFailure(caught);
+              }
+            });
+      }
+
+      @Override
+      public void onClose(CloseEvent<PopupPanel> event) {
+        super.onClose(event);
+        b.setEnabled(true);
+      }
+    }.center();
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsAction.java
index 5e2b8e3..faf2516 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PatchSetsAction.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.info.ChangeInfo.EditInfo;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.Widget;
 
@@ -23,6 +24,7 @@
   private final PatchSetsBox revisionBox;
 
   PatchSetsAction(
+      Project.NameKey project,
       Change.Id changeId,
       String revision,
       EditInfo edit,
@@ -30,7 +32,7 @@
       UIObject relativeTo,
       Widget downloadButton) {
     super(style, relativeTo, downloadButton);
-    this.revisionBox = new PatchSetsBox(changeId, revision, edit);
+    this.revisionBox = new PatchSetsBox(project, changeId, revision, edit);
   }
 
   @Override
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 189df08..35cab4e 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
@@ -29,6 +29,7 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.dom.client.Element;
@@ -103,6 +104,7 @@
   }
 
   private final Change.Id changeId;
+  private final Project.NameKey project;
   private final String revision;
   private final EditInfo edit;
   private boolean loaded;
@@ -111,7 +113,8 @@
   @UiField FlexTable table;
   @UiField Style style;
 
-  PatchSetsBox(Change.Id changeId, String revision, EditInfo edit) {
+  PatchSetsBox(Project.NameKey project, Change.Id changeId, String revision, EditInfo edit) {
+    this.project = project;
     this.changeId = changeId;
     this.revision = revision;
     this.edit = edit;
@@ -121,7 +124,7 @@
   @Override
   protected void onLoad() {
     if (!loaded) {
-      RestApi call = ChangeApi.detail(changeId.get());
+      RestApi call = ChangeApi.detail(project.get(), changeId.get());
       ChangeList.addOptions(
           call, EnumSet.of(ListChangesOption.ALL_COMMITS, ListChangesOption.ALL_REVISIONS));
       call.get(
@@ -189,9 +192,6 @@
     }
 
     sb.openTd().setStyleName(style.legacy_id());
-    if (r.draft()) {
-      sb.append(Resources.C.draft()).append(' ');
-    }
     sb.append(r.id());
     sb.closeTd();
 
@@ -219,7 +219,7 @@
   }
 
   private String url(RevisionInfo r) {
-    return PageLinks.toChange(changeId, r.id());
+    return PageLinks.toChange(project, changeId, r.id());
   }
 
   private void closeParent() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java
index 3b96a12..7668f0f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/PathSuggestOracle.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
@@ -28,17 +29,19 @@
 
 class PathSuggestOracle extends HighlightSuggestOracle {
 
+  private final Project.NameKey project;
   private final Change.Id changeId;
   private final RevisionInfo revision;
 
-  PathSuggestOracle(Change.Id changeId, RevisionInfo revision) {
+  PathSuggestOracle(Project.NameKey project, Change.Id changeId, RevisionInfo revision) {
+    this.project = project;
     this.changeId = changeId;
     this.revision = revision;
   }
 
   @Override
-  protected void onRequestSuggestions(final Request req, final Callback cb) {
-    RestApi api = ChangeApi.revision(changeId.get(), revision.name()).view("files");
+  protected void onRequestSuggestions(Request req, Callback cb) {
+    RestApi api = ChangeApi.revision(project.get(), changeId.get(), revision.name()).view("files");
     if (req.getQuery() != null) {
       api.addParameter("q", req.getQuery() == null ? "" : req.getQuery());
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ProjectChangeId.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ProjectChangeId.java
new file mode 100644
index 0000000..684867b
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ProjectChangeId.java
@@ -0,0 +1,117 @@
+// 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.client.change;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.Objects;
+
+/** Provides logic for parsing a numeric change id and project from a URL. */
+public class ProjectChangeId {
+
+  /** Parses a {@link ProjectChangeId} from it's string representation. */
+  public static ProjectChangeId create(String token) {
+    String mutableToken = token;
+    // Try parsing /c/project/+/numericChangeId where token is project/+/numericChangeId
+    int delimiter = mutableToken.indexOf(PageLinks.PROJECT_CHANGE_DELIMITER);
+    Project.NameKey project = null;
+    if (delimiter > 0) {
+      project = new Project.NameKey(token.substring(0, delimiter));
+      mutableToken =
+          mutableToken.substring(delimiter + PageLinks.PROJECT_CHANGE_DELIMITER.length());
+    }
+
+    // Try parsing /c/numericChangeId where token is numericChangeId
+    int s = mutableToken.indexOf('/');
+    if (s > 0) {
+      mutableToken = mutableToken.substring(0, s);
+    }
+    // Special case: project/+/1233,edit/
+    s = mutableToken.indexOf(",edit");
+    if (s > 0) {
+      mutableToken = mutableToken.substring(0, s);
+    }
+    Integer cId = tryParse(mutableToken);
+    if (cId != null) {
+      return new ProjectChangeId(project, new Change.Id(cId));
+    }
+
+    throw new IllegalArgumentException(token + " is not a valid change identifier");
+  }
+
+  @Nullable private final Project.NameKey project;
+  private final Change.Id changeId;
+
+  @VisibleForTesting
+  ProjectChangeId(@Nullable Project.NameKey project, Change.Id changeId) {
+    this.project = project;
+    this.changeId = changeId;
+  }
+
+  @Nullable
+  public Project.NameKey getProject() {
+    return project;
+  }
+
+  public Change.Id getChangeId() {
+    return changeId;
+  }
+
+  /**
+   * Calculate the length of the string representation of the change ID that was parsed from the
+   * token.
+   *
+   * @return the length of the {@link com.google.gerrit.reviewdb.client.Change.Id} if no project was
+   *     parsed from the token. The length of {@link
+   *     com.google.gerrit.reviewdb.client.Project.NameKey} + the delimiter + the length of {@link
+   *     com.google.gerrit.reviewdb.client.Change.Id} otherwise.
+   */
+  public int identifierLength() {
+    if (project == null) {
+      return String.valueOf(changeId).length();
+    }
+    return PageLinks.toChangeId(project, changeId).length();
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (obj instanceof ProjectChangeId) {
+      ProjectChangeId other = (ProjectChangeId) obj;
+      return Objects.equals(changeId, other.changeId) && Objects.equals(project, other.project);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(changeId, project);
+  }
+
+  @Override
+  public String toString() {
+    return "ProjectChangeId.Result{changeId: " + changeId + ", project: " + project + "}";
+  }
+
+  private static Integer tryParse(String s) {
+    try {
+      return Integer.parseInt(s);
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java
index c4a74f5..56cc7a7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/QuickApprove.java
@@ -24,6 +24,7 @@
 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.Project;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
@@ -33,6 +34,7 @@
 /** Applies a label with one mouse click. */
 class QuickApprove extends Button implements ClickHandler {
   private Change.Id changeId;
+  private Project.NameKey project;
   private String revision;
   private ReviewInput input;
   private ReplyAction replyAction;
@@ -47,7 +49,7 @@
       setVisible(false);
       return;
     }
-    if (info.revision(commit).isEdit() || info.revision(commit).draft()) {
+    if (info.revision(commit).isEdit()) {
       setVisible(false);
       return;
     }
@@ -71,6 +73,7 @@
 
     if (qName != null) {
       changeId = info.legacyId();
+      project = info.projectNameKey();
       revision = commit;
       input = ReviewInput.create();
       input.drafts(DraftHandling.PUBLISH_ALL_REVISIONS);
@@ -93,14 +96,14 @@
     if (replyAction != null && replyAction.isVisible()) {
       replyAction.quickApprove(input);
     } else {
-      ChangeApi.revision(changeId.get(), revision)
+      ChangeApi.revision(project.get(), changeId.get(), revision)
           .view("review")
           .post(
               input,
               new GerritCallback<ReviewInput>() {
                 @Override
                 public void onSuccess(ReviewInput result) {
-                  Gerrit.display(PageLinks.toChange(changeId));
+                  Gerrit.display(PageLinks.toChange(project, changeId));
                 }
               });
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java
index 147f2bc..0e3e835 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RebaseAction.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.client.ui.RebaseDialog;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.event.logical.shared.CloseEvent;
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.PopupPanel;
@@ -28,7 +29,7 @@
 class RebaseAction {
   static void call(
       final Button b,
-      final String project,
+      final Project.NameKey project,
       final String branch,
       final Change.Id id,
       final String revision,
@@ -39,6 +40,7 @@
       @Override
       public void onSend() {
         ChangeApi.rebase(
+            project.get(),
             id.get(),
             revision,
             getBase(),
@@ -47,7 +49,7 @@
               public void onSuccess(ChangeInfo result) {
                 sent = true;
                 hide();
-                Gerrit.display(PageLinks.toChange(id));
+                Gerrit.display(PageLinks.toChange(project, id));
               }
 
               @Override
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 d5d5f36..81a94a2 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
@@ -198,12 +198,12 @@
     getTab(Tab.SUBMITTED_TOGETHER).setShowSubmittable(true);
   }
 
-  void set(final ChangeInfo info, final String revision) {
+  void set(ChangeInfo info, String revision) {
     if (info.status().isOpen()) {
       setForOpenChange(info, revision);
     }
 
-    ChangeApi.revision(info.legacyId().get(), revision)
+    ChangeApi.revision(info.project(), info.legacyId().get(), revision)
         .view("related")
         .get(
             new TabCallback<RelatedInfo>(Tab.RELATED_CHANGES, info.project(), revision) {
@@ -224,7 +224,7 @@
         new TabChangeListCallback(Tab.CHERRY_PICKS, info.project(), revision));
 
     if (info.currentRevision() != null && info.currentRevision().equals(revision)) {
-      ChangeApi.change(info.legacyId().get())
+      ChangeApi.change(info.project(), info.legacyId().get())
           .view("submitted_together")
           .get(new TabChangeListCallback(Tab.SUBMITTED_TOGETHER, info.project(), revision));
     }
@@ -232,9 +232,13 @@
     if (!Gerrit.info().change().isSubmitWholeTopicEnabled()
         && info.topic() != null
         && !"".equals(info.topic())) {
-      StringBuilder topicQuery = new StringBuilder();
-      topicQuery.append("status:open");
-      topicQuery.append(" ").append(op("topic", info.topic()));
+      StringBuilder topicQuery =
+          new StringBuilder()
+              .append("status:open")
+              .append(" ")
+              .append(op("-change", info.legacyId().get()))
+              .append(" ")
+              .append(op("topic", info.topic()));
       ChangeList.query(
           topicQuery.toString(),
           EnumSet.of(
@@ -246,7 +250,7 @@
     }
   }
 
-  private void setForOpenChange(final ChangeInfo info, final String revision) {
+  private void setForOpenChange(ChangeInfo info, String revision) {
     if (info.mergeable()) {
       StringBuilder conflictsQuery = new StringBuilder();
       conflictsQuery.append("status:open");
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 9ffbad8..c53427b 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
@@ -20,6 +20,7 @@
 import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.Scheduler;
@@ -247,8 +248,9 @@
     }
   }
 
-  @SuppressWarnings("serial")
   private class RowSafeHtml implements SafeHtml {
+    private static final long serialVersionUID = 1L;
+
     private String html;
     private ChangeAndCommit info;
     private final boolean notConnected;
@@ -332,7 +334,7 @@
 
     private String url() {
       if (info.hasChangeNumber() && info.hasRevisionNumber()) {
-        return "#" + PageLinks.toChange(info.patchSetId());
+        return "#" + PageLinks.toChange(new Project.NameKey(info.project()), info.patchSetId());
       }
       return null;
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java
index cc24fe6..1e7063a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileAction.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.event.logical.shared.CloseEvent;
 import com.google.gwt.event.logical.shared.CloseHandler;
 import com.google.gwt.user.client.ui.PopupPanel;
@@ -23,6 +24,7 @@
 import com.google.gwtexpui.globalkey.client.GlobalKey;
 
 class RenameFileAction {
+  private final Project.NameKey project;
   private final Change.Id changeId;
   private final RevisionInfo revision;
   private final ChangeScreen.Style style;
@@ -32,7 +34,12 @@
   private PopupPanel popup;
 
   RenameFileAction(
-      Change.Id changeId, RevisionInfo revision, ChangeScreen.Style style, Widget renameButton) {
+      Project.NameKey project,
+      Change.Id changeId,
+      RevisionInfo revision,
+      ChangeScreen.Style style,
+      Widget renameButton) {
+    this.project = project;
     this.changeId = changeId;
     this.revision = revision;
     this.style = style;
@@ -46,7 +53,7 @@
     }
 
     if (renameBox == null) {
-      renameBox = new RenameFileBox(changeId, revision);
+      renameBox = new RenameFileBox(project, changeId, revision);
     }
     renameBox.clearPath();
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java
index a36b8ef..f288dbe 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RenameFileBox.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.client.ui.RemoteSuggestBox;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.logical.shared.CloseEvent;
@@ -41,6 +42,7 @@
 
   private static final Binder uiBinder = GWT.create(Binder.class);
 
+  private final Project.NameKey project;
   private final Change.Id changeId;
 
   @UiField Button rename;
@@ -51,10 +53,11 @@
 
   @UiField NpTextBox newPath;
 
-  RenameFileBox(Change.Id changeId, RevisionInfo revision) {
+  RenameFileBox(Project.NameKey project, Change.Id changeId, RevisionInfo revision) {
+    this.project = project;
     this.changeId = changeId;
 
-    path = new RemoteSuggestBox(new PathSuggestOracle(changeId, revision));
+    path = new RemoteSuggestBox(new PathSuggestOracle(project, changeId, revision));
     path.addCloseHandler(
         new CloseHandler<RemoteSuggestBox>() {
           @Override
@@ -82,13 +85,14 @@
   private void rename(String path, String newPath) {
     hide();
     ChangeEditApi.rename(
+        project.get(),
         changeId.get(),
         path,
         newPath,
         new AsyncCallback<VoidResult>() {
           @Override
           public void onSuccess(VoidResult result) {
-            Gerrit.display(PageLinks.toChangeInEditMode(changeId));
+            Gerrit.display(PageLinks.toChangeInEditMode(project, changeId));
           }
 
           @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java
index 1c21cbf..ff09ff5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReplyAction.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.event.logical.shared.CloseEvent;
 import com.google.gwt.event.logical.shared.CloseHandler;
@@ -31,6 +32,7 @@
 
 class ReplyAction {
   private final PatchSet.Id psId;
+  private final Project.NameKey project;
   private final String revision;
   private final boolean hasDraftComments;
   private final ChangeScreen.Style style;
@@ -53,6 +55,7 @@
       Widget replyButton,
       Widget quickApproveButton) {
     this.psId = new PatchSet.Id(info.legacyId(), info.revisions().get(revision)._number());
+    this.project = info.projectNameKey();
     this.revision = revision;
     this.hasDraftComments = hasDraftComments;
     this.style = style;
@@ -90,7 +93,7 @@
     }
 
     if (replyBox == null) {
-      replyBox = new ReplyBox(clp, psId, revision, allLabels, permittedLabels);
+      replyBox = new ReplyBox(clp, project, psId, revision, allLabels, permittedLabels);
       allLabels = null;
       permittedLabels = null;
     }
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 2a926b6..80b1796 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
@@ -35,6 +35,7 @@
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
@@ -92,6 +93,7 @@
   }
 
   private final CommentLinkProcessor clp;
+  private final Project.NameKey project;
   private final PatchSet.Id psId;
   private final String revision;
   private ReviewInput in = ReviewInput.create();
@@ -109,14 +111,16 @@
 
   ReplyBox(
       CommentLinkProcessor clp,
+      Project.NameKey project,
       PatchSet.Id psId,
       String revision,
       NativeMap<LabelInfo> all,
       NativeMap<JsArrayString> permitted) {
     this.clp = clp;
+    this.project = project;
     this.psId = psId;
     this.revision = revision;
-    this.lc = new LocalComments(psId.getParentKey());
+    this.lc = new LocalComments(project, psId.getParentKey());
     initWidget(uiBinder.createAndBindUi(this));
 
     List<String> names = new ArrayList<>(permitted.keySet());
@@ -160,7 +164,7 @@
       message.setText(lc.getReplyComment());
       lc.removeReplyComment();
     }
-    ChangeApi.drafts(psId.getParentKey().get())
+    ChangeApi.drafts(project.get(), psId.getParentKey().get())
         .get(
             new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
               @Override
@@ -218,18 +222,18 @@
     // e.g. a draft was modified in another tab since we last looked it up.
     in.drafts(DraftHandling.PUBLISH_ALL_REVISIONS);
     in.prePost();
-    ChangeApi.revision(psId.getParentKey().get(), revision)
+    ChangeApi.revision(project.get(), psId.getParentKey().get(), revision)
         .view("review")
         .post(
             in,
             new GerritCallback<ReviewInput>() {
               @Override
               public void onSuccess(ReviewInput result) {
-                Gerrit.display(PageLinks.toChange(psId));
+                Gerrit.display(PageLinks.toChange(project, psId));
               }
 
               @Override
-              public void onFailure(final Throwable caught) {
+              public void onFailure(Throwable caught) {
                 if (RestApi.isNotSignedIn(caught)) {
                   lc.setReplyComment(message.getText());
                 }
@@ -425,12 +429,14 @@
     JsArray<CommentInfo> l = m.get(Patch.COMMIT_MSG);
     if (l != null) {
       comments.add(
-          new FileComments(clp, psId, Util.C.commitMessage(), copyPath(Patch.COMMIT_MSG, l)));
+          new FileComments(
+              clp, project, psId, Util.C.commitMessage(), copyPath(Patch.COMMIT_MSG, l)));
     }
     l = m.get(Patch.MERGE_LIST);
     if (l != null) {
       comments.add(
-          new FileComments(clp, psId, Util.C.commitMessage(), copyPath(Patch.MERGE_LIST, l)));
+          new FileComments(
+              clp, project, psId, Util.C.commitMessage(), copyPath(Patch.MERGE_LIST, l)));
     }
 
     List<String> paths = new ArrayList<>(m.keySet());
@@ -438,7 +444,7 @@
 
     for (String path : paths) {
       if (!Patch.isMagic(path)) {
-        comments.add(new FileComments(clp, psId, path, copyPath(path, m.get(path))));
+        comments.add(new FileComments(clp, project, psId, path, copyPath(path, m.get(path))));
       }
     }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java
index ebc3d68..aa3a9ef 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RestoreAction.java
@@ -20,25 +20,29 @@
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.user.client.ui.Button;
 
 class RestoreAction extends ActionMessageBox {
+  private final Project.NameKey project;
   private final Change.Id id;
 
-  RestoreAction(Button b, Change.Id id) {
+  RestoreAction(Button b, Project.NameKey project, Change.Id id) {
     super(b);
+    this.project = project;
     this.id = id;
   }
 
   @Override
   void send(String message) {
     ChangeApi.restore(
+        project.get(),
         id.get(),
         message,
         new GerritCallback<ChangeInfo>() {
           @Override
           public void onSuccess(ChangeInfo result) {
-            Gerrit.display(PageLinks.toChange(id));
+            Gerrit.display(PageLinks.toChange(project, id));
             hide();
           }
         });
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java
index f216af8..3fba125 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/RevertAction.java
@@ -22,13 +22,19 @@
 import com.google.gerrit.client.ui.TextAreaActionDialog;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.event.logical.shared.CloseEvent;
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.PopupPanel;
 
 class RevertAction {
+
   static void call(
-      final Button b, final Change.Id id, final String revision, final String commitSubject) {
+      final Button b,
+      Change.Id id,
+      Project.NameKey project,
+      String revision,
+      String commitSubject) {
     // TODO Replace ActionDialog with a nicer looking display.
     b.setEnabled(false);
     new TextAreaActionDialog(Util.C.revertChangeTitle(), Util.C.headingRevertMessage()) {
@@ -40,6 +46,7 @@
       @Override
       public void onSend() {
         ChangeApi.revert(
+            project.get(),
             id.get(),
             getMessageText(),
             new GerritCallback<ChangeInfo>() {
@@ -47,7 +54,7 @@
               public void onSuccess(ChangeInfo result) {
                 sent = true;
                 hide();
-                Gerrit.display(PageLinks.toChange(result.legacyId()));
+                Gerrit.display(PageLinks.toChange(result.projectNameKey(), result.legacyId()));
               }
 
               @Override
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 8609774..4e464df 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
@@ -22,6 +22,7 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountSuggestOracle;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
@@ -31,11 +32,12 @@
 
 /** REST API based suggestion Oracle for reviewers. */
 public class ReviewerSuggestOracle extends HighlightSuggestOracle {
+  private Project.NameKey project;
   private Change.Id changeId;
 
   @Override
-  protected void onRequestSuggestions(final Request req, final Callback cb) {
-    ChangeApi.suggestReviewers(changeId.get(), req.getQuery(), req.getLimit(), false)
+  protected void onRequestSuggestions(Request req, Callback cb) {
+    ChangeApi.suggestReviewers(project.get(), changeId.get(), req.getQuery(), req.getLimit(), false)
         .get(
             new GerritCallback<JsArray<SuggestReviewerInfo>>() {
               @Override
@@ -56,11 +58,12 @@
   }
 
   @Override
-  public void requestDefaultSuggestions(final Request req, final Callback cb) {
+  public void requestDefaultSuggestions(Request req, Callback cb) {
     requestSuggestions(req, cb);
   }
 
-  public void setChange(Change.Id changeId) {
+  public void setChange(Project.NameKey project, Change.Id changeId) {
+    this.project = project;
     this.changeId = changeId;
   }
 
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 cd880a3..859af19 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
@@ -31,6 +31,7 @@
 import com.google.gerrit.client.ui.RemoteSuggestBox;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
@@ -78,6 +79,7 @@
 
   private ReviewerSuggestOracle reviewerSuggestOracle;
   private Change.Id changeId;
+  private Project.NameKey project;
 
   Reviewers() {
     reviewerSuggestOracle = new ReviewerSuggestOracle();
@@ -118,8 +120,9 @@
 
   void set(ChangeInfo info) {
     this.changeId = info.legacyId();
+    this.project = info.projectNameKey();
     display(info);
-    reviewerSuggestOracle.setChange(changeId);
+    reviewerSuggestOracle.setChange(project, changeId);
     addReviewerIcon.setVisible(Gerrit.isSignedIn());
   }
 
@@ -151,12 +154,12 @@
     suggestBox.setServeSuggestionsOnOracle(false);
   }
 
-  private void addReviewer(final String reviewer, boolean confirmed) {
+  private void addReviewer(String reviewer, boolean confirmed) {
     if (reviewer.isEmpty()) {
       return;
     }
 
-    ChangeApi.reviewers(changeId.get())
+    ChangeApi.reviewers(project.get(), changeId.get())
         .post(
             PostInput.create(reviewer, confirmed),
             new GerritCallback<PostResult>() {
@@ -208,6 +211,7 @@
 
   void updateReviewerList() {
     ChangeApi.detail(
+        project.get(),
         changeId.get(),
         new GerritCallback<ChangeInfo>() {
           @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java
index 69a7ca5..4446e65 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/SubmitAction.java
@@ -29,6 +29,7 @@
     if (ChangeGlue.onSubmitChange(changeInfo, revisionInfo)) {
       final Change.Id changeId = changeInfo.legacyId();
       ChangeApi.submit(
+          changeInfo.project(),
           changeId.get(),
           revisionInfo.name(),
           new GerritCallback<SubmitInfo>() {
@@ -48,7 +49,7 @@
             }
 
             private void redisplay() {
-              Gerrit.display(PageLinks.toChange(changeId));
+              Gerrit.display(PageLinks.toChange(changeInfo.projectNameKey(), changeId));
             }
           });
     }
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 f08414a..f5c921b 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
@@ -21,6 +21,7 @@
 import com.google.gerrit.client.ui.InlineHyperlink;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -45,6 +46,7 @@
   private static final Binder uiBinder = GWT.create(Binder.class);
 
   private PatchSet.Id psId;
+  private Project.NameKey project;
   private boolean canEdit;
 
   @UiField Element show;
@@ -72,6 +74,7 @@
     canEdit = info.hasActions() && info.actions().containsKey("topic");
 
     psId = new PatchSet.Id(info.legacyId(), info.revisions().get(revision)._number());
+    project = info.projectNameKey();
 
     initTopicLink(info);
     editIcon.setVisible(canEdit);
@@ -124,12 +127,13 @@
   @UiHandler("save")
   void onSave(@SuppressWarnings("unused") ClickEvent e) {
     ChangeApi.topic(
+        project.get(),
         psId.getParentKey().get(),
         input.getValue().trim(),
         new GerritCallback<String>() {
           @Override
           public void onSuccess(String result) {
-            Gerrit.display(PageLinks.toChange(psId));
+            Gerrit.display(PageLinks.toChange(project, psId));
           }
         });
     onCancel(null);
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 f37cbc2..0465902 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
@@ -46,11 +46,12 @@
   private final Account.Id ownerId;
   private final boolean mine;
   private ChangeTable table;
+  private ChangeTable.Section workInProgress;
   private ChangeTable.Section outgoing;
   private ChangeTable.Section incoming;
   private ChangeTable.Section closed;
 
-  public AccountDashboardScreen(final Account.Id id) {
+  public AccountDashboardScreen(Account.Id id) {
     ownerId = id;
     mine = Gerrit.isSignedIn() && ownerId.equals(Gerrit.getUserAccount().getId());
   }
@@ -64,7 +65,7 @@
             keysNavigation.add(
                 new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
                   @Override
-                  public void onKeyPress(final KeyPressEvent event) {
+                  public void onKeyPress(KeyPressEvent event) {
                     Gerrit.display(getToken());
                   }
                 });
@@ -72,11 +73,15 @@
         };
     table.addStyleName(Gerrit.RESOURCES.css().accountDashboard());
 
+    workInProgress = new ChangeTable.Section();
     outgoing = new ChangeTable.Section();
     incoming = new ChangeTable.Section();
     closed = new ChangeTable.Section();
 
     String who = mine ? "self" : ownerId.toString();
+    workInProgress.setTitleWidget(
+        new InlineHyperlink(
+            Util.C.workInProgress(), PageLinks.toChangeQuery(queryWorkInProgress(who))));
     outgoing.setTitleWidget(
         new InlineHyperlink(Util.C.outgoingReviews(), PageLinks.toChangeQuery(queryOutgoing(who))));
     incoming.setTitleWidget(
@@ -85,6 +90,7 @@
     closed.setTitleWidget(
         new InlineHyperlink(Util.C.recentlyClosed(), PageLinks.toChangeQuery(queryClosed(who))));
 
+    table.addSection(workInProgress);
     table.addSection(outgoing);
     table.addSection(incoming);
     table.addSection(closed);
@@ -92,8 +98,12 @@
     table.setSavePointerId("owner:" + ownerId);
   }
 
+  private static String queryWorkInProgress(String who) {
+    return "is:open is:wip owner:" + who;
+  }
+
   private static String queryOutgoing(String who) {
-    return "is:open owner:" + who;
+    return "is:open -is:wip owner:" + who;
   }
 
   private static String queryIncoming(String who) {
@@ -101,7 +111,7 @@
         + who
         + " -owner:"
         + who
-        + " -star:ignore) OR assignee:"
+        + " -is:ignored) OR assignee:"
         + who
         + ")";
   }
@@ -123,6 +133,7 @@
           }
         },
         mine ? MY_DASHBOARD_OPTIONS : DashboardTable.OPTIONS,
+        queryWorkInProgress(who),
         queryOutgoing(who),
         queryIncoming(who),
         queryClosed(who) + " -age:4w limit:10");
@@ -142,9 +153,10 @@
       return;
     }
 
-    ChangeList out = result.get(0);
-    ChangeList in = result.get(1);
-    ChangeList done = result.get(2);
+    ChangeList wip = result.get(0);
+    ChangeList out = result.get(1);
+    ChangeList in = result.get(2);
+    ChangeList done = result.get(3);
 
     if (mine) {
       setWindowTitle(Util.C.myDashboardTitle());
@@ -167,7 +179,8 @@
 
     Collections.sort(Natives.asList(out), outComparator());
 
-    table.updateColumnsForLabels(out, in, done);
+    table.updateColumnsForLabels(wip, out, in, done);
+    workInProgress.display(wip);
     outgoing.display(out);
     incoming.display(in);
     closed.display(done);
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 f8a9ba1..02be8c7 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
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.client.changes;
 
-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;
@@ -23,7 +22,7 @@
 import com.google.gerrit.client.rpc.CallbackGroup.Callback;
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.RestApi;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.user.client.rpc.AsyncCallback;
@@ -31,19 +30,14 @@
 /** A collection of static methods which work on the Gerrit REST API for specific changes. */
 public class ChangeApi {
   /** Abandon the change, ending its review. */
-  public static void abandon(int id, String msg, AsyncCallback<ChangeInfo> cb) {
+  public static void abandon(
+      @Nullable String project, int id, String msg, AsyncCallback<ChangeInfo> cb) {
     MessageInput input = MessageInput.create();
     input.message(emptyToNull(msg));
-    call(id, "abandon").post(input, cb);
+    call(project, id, "abandon").post(input, cb);
   }
 
-  /**
-   * Create a new change.
-   *
-   * <p>The new change is created as DRAFT unless the draft workflow is disabled by
-   * `change.allowDrafts = false` in the configuration, in which case the new change is created as
-   * NEW.
-   */
+  /** Create a new work-in-progress change. */
   public static void createChange(
       String project,
       String branch,
@@ -57,31 +51,31 @@
     input.topic(emptyToNull(topic));
     input.subject(emptyToNull(subject));
     input.baseChange(emptyToNull(base));
-
-    if (Gerrit.info().change().allowDrafts()) {
-      input.status(Change.Status.DRAFT.toString());
-    }
+    input.workInProgress(true);
 
     new RestApi("/changes/").post(input, cb);
   }
 
   /** Restore a previously abandoned change to be open again. */
-  public static void restore(int id, String msg, AsyncCallback<ChangeInfo> cb) {
+  public static void restore(
+      @Nullable String project, int id, String msg, AsyncCallback<ChangeInfo> cb) {
     MessageInput input = MessageInput.create();
     input.message(emptyToNull(msg));
-    call(id, "restore").post(input, cb);
+    call(project, id, "restore").post(input, cb);
   }
 
   /** Create a new change that reverts the delta caused by this change. */
-  public static void revert(int id, String msg, AsyncCallback<ChangeInfo> cb) {
+  public static void revert(
+      @Nullable String project, int id, String msg, AsyncCallback<ChangeInfo> cb) {
     MessageInput input = MessageInput.create();
     input.message(emptyToNull(msg));
-    call(id, "revert").post(input, cb);
+    call(project, id, "revert").post(input, cb);
   }
 
   /** Update the topic of a change. */
-  public static void topic(int id, String topic, AsyncCallback<String> cb) {
-    RestApi call = call(id, "topic");
+  public static void topic(
+      @Nullable String project, int id, String topic, AsyncCallback<String> cb) {
+    RestApi call = call(project, id, "topic");
     topic = emptyToNull(topic);
     if (topic != null) {
       TopicInput input = TopicInput.create();
@@ -92,169 +86,197 @@
     }
   }
 
-  public static void detail(int id, AsyncCallback<ChangeInfo> cb) {
-    detail(id).get(cb);
+  public static void detail(@Nullable String project, int id, AsyncCallback<ChangeInfo> cb) {
+    detail(project, id).get(cb);
   }
 
-  public static RestApi detail(int id) {
-    return call(id, "detail");
+  public static RestApi detail(@Nullable String project, int id) {
+    return call(project, 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 blame(@Nullable String project, PatchSet.Id id, String path, boolean base) {
+    return revision(project, id).view("files").id(path).view("blame").addParameter("base", base);
   }
 
-  public static RestApi actions(int id, String revision) {
+  public static RestApi actions(@Nullable String project, int id, String revision) {
     if (revision == null || revision.equals("")) {
       revision = "current";
     }
-    return call(id, revision, "actions");
+    return call(project, id, revision, "actions");
   }
 
-  public static void deleteAssignee(int id, AsyncCallback<AccountInfo> cb) {
-    change(id).view("assignee").delete(cb);
+  public static void deleteAssignee(
+      @Nullable String project, int id, AsyncCallback<AccountInfo> cb) {
+    change(project, id).view("assignee").delete(cb);
   }
 
-  public static void setAssignee(int id, String user, AsyncCallback<AccountInfo> cb) {
+  public static void setAssignee(
+      @Nullable String project, int id, String user, AsyncCallback<AccountInfo> cb) {
     AssigneeInput input = AssigneeInput.create();
     input.assignee(user);
-    change(id).view("assignee").put(input, cb);
+    change(project, id).view("assignee").put(input, cb);
   }
 
-  public static RestApi comments(int id) {
-    return call(id, "comments");
+  public static void markPrivate(
+      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
+    change(project, id).view("private").post(PrivateInput.create(), cb);
   }
 
-  public static RestApi drafts(int id) {
-    return call(id, "drafts");
+  public static void unmarkPrivate(
+      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
+    change(project, id).view("private.delete").post(PrivateInput.create(), cb);
   }
 
-  public static void edit(int id, AsyncCallback<EditInfo> cb) {
-    edit(id).get(cb);
+  public static RestApi comments(@Nullable String project, int id) {
+    return call(project, id, "comments");
   }
 
-  public static void editWithFiles(int id, AsyncCallback<EditInfo> cb) {
-    edit(id).addParameterTrue("list").get(cb);
+  public static RestApi drafts(@Nullable String project, int id) {
+    return call(project, id, "drafts");
   }
 
-  public static RestApi edit(int id) {
-    return change(id).view("edit");
+  public static void edit(@Nullable String project, int id, AsyncCallback<EditInfo> cb) {
+    edit(project, id).get(cb);
   }
 
-  public static RestApi editWithCommands(int id) {
-    return edit(id).addParameterTrue("download-commands");
+  public static void editWithFiles(@Nullable String project, int id, AsyncCallback<EditInfo> cb) {
+    edit(project, id).addParameterTrue("list").get(cb);
   }
 
-  public static void includedIn(int id, AsyncCallback<IncludedInInfo> cb) {
-    call(id, "in").get(cb);
+  public static RestApi edit(@Nullable String project, int id) {
+    return change(project, id).view("edit");
   }
 
-  public static RestApi revision(int id, String revision) {
-    return change(id).view("revisions").id(revision);
+  public static RestApi editWithCommands(@Nullable String project, int id) {
+    return edit(project, id).addParameterTrue("download-commands");
   }
 
-  public static RestApi revision(PatchSet.Id id) {
+  public static void includedIn(
+      @Nullable String project, int id, AsyncCallback<IncludedInInfo> cb) {
+    call(project, id, "in").get(cb);
+  }
+
+  public static RestApi revision(@Nullable String project, int id, String revision) {
+    return change(project, id).view("revisions").id(revision);
+  }
+
+  public static RestApi revision(@Nullable String project, PatchSet.Id id) {
     int cn = id.getParentKey().get();
     String revision = RevisionInfoCache.get(id);
     if (revision != null) {
-      return revision(cn, revision);
+      return revision(project, cn, revision);
     }
-    return change(cn).view("revisions").id(id.get());
+    return change(project, cn).view("revisions").id(id.get());
   }
 
-  public static RestApi reviewers(int id) {
-    return change(id).view("reviewers");
+  public static RestApi reviewers(@Nullable String project, int id) {
+    return change(project, id).view("reviewers");
   }
 
-  public static RestApi suggestReviewers(int id, String q, int n, boolean e) {
-    RestApi api = change(id).view("suggest_reviewers").addParameter("n", n).addParameter("e", e);
+  public static RestApi suggestReviewers(
+      @Nullable String project, int id, String q, int n, boolean e) {
+    RestApi api =
+        change(project, id).view("suggest_reviewers").addParameter("n", n).addParameter("e", e);
     if (q != null) {
       api.addParameter("q", q);
     }
     return api;
   }
 
-  public static RestApi vote(int id, int reviewer, String vote) {
-    return reviewer(id, reviewer).view("votes").id(vote);
+  public static RestApi vote(@Nullable String project, int id, int reviewer, String vote) {
+    return reviewer(project, id, reviewer).view("votes").id(vote);
   }
 
-  public static RestApi reviewer(int id, int reviewer) {
-    return change(id).view("reviewers").id(reviewer);
+  public static RestApi reviewer(@Nullable String project, int id, int reviewer) {
+    return change(project, id).view("reviewers").id(reviewer);
   }
 
-  public static RestApi reviewer(int id, String reviewer) {
-    return change(id).view("reviewers").id(reviewer);
+  public static RestApi reviewer(@Nullable String project, int id, String reviewer) {
+    return change(project, id).view("reviewers").id(reviewer);
   }
 
-  public static RestApi hashtags(int changeId) {
-    return change(changeId).view("hashtags");
+  public static RestApi hashtags(@Nullable String project, int changeId) {
+    return change(project, changeId).view("hashtags");
   }
 
-  public static RestApi hashtag(int changeId, String hashtag) {
-    return change(changeId).view("hashtags").id(hashtag);
+  public static RestApi hashtag(@Nullable String project, int changeId, String hashtag) {
+    return change(project, changeId).view("hashtags").id(hashtag);
   }
 
   /** Submit a specific revision of a change. */
   public static void cherrypick(
-      int id, String commit, String destination, String message, AsyncCallback<ChangeInfo> cb) {
+      String project,
+      int id,
+      String commit,
+      String destination,
+      String message,
+      AsyncCallback<ChangeInfo> cb) {
     CherryPickInput cherryPickInput = CherryPickInput.create();
     cherryPickInput.setMessage(message);
     cherryPickInput.setDestination(destination);
-    call(id, commit, "cherrypick").post(cherryPickInput, cb);
+    call(project, id, commit, "cherrypick").post(cherryPickInput, cb);
+  }
+
+  /** Move change to another branch. */
+  public static void move(
+      String project, int id, String destination, String message, AsyncCallback<ChangeInfo> cb) {
+    MoveInput moveInput = MoveInput.create();
+    moveInput.setMessage(message);
+    moveInput.setDestinationBranch(destination);
+    change(project, id).view("move").post(moveInput, cb);
   }
 
   /** Edit commit message for specific revision of a change. */
   public static void message(
-      int id, String commit, String message, AsyncCallback<JavaScriptObject> cb) {
+      @Nullable String project,
+      int id,
+      String commit,
+      String message,
+      AsyncCallback<JavaScriptObject> cb) {
     CherryPickInput input = CherryPickInput.create();
     input.setMessage(message);
-    call(id, commit, "message").post(input, cb);
+    call(project, id, commit, "message").post(input, cb);
   }
 
   /** Submit a specific revision of a change. */
-  public static void submit(int id, String commit, AsyncCallback<SubmitInfo> cb) {
+  public static void submit(
+      @Nullable String project, int id, String commit, AsyncCallback<SubmitInfo> cb) {
     JavaScriptObject in = JavaScriptObject.createObject();
-    call(id, commit, "submit").post(in, cb);
-  }
-
-  /** Publish a specific revision of a draft change. */
-  public static void publish(int id, String commit, AsyncCallback<JavaScriptObject> cb) {
-    JavaScriptObject in = JavaScriptObject.createObject();
-    call(id, commit, "publish").post(in, cb);
+    call(project, id, commit, "submit").post(in, cb);
   }
 
   /** Delete a specific draft change. */
-  public static void deleteChange(int id, AsyncCallback<JavaScriptObject> cb) {
-    change(id).delete(cb);
-  }
-
-  /** Delete a specific draft patch set. */
-  public static void deleteRevision(int id, String commit, AsyncCallback<JavaScriptObject> cb) {
-    revision(id, commit).delete(cb);
+  public static void deleteChange(
+      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
+    change(project, id).delete(cb);
   }
 
   /** Delete change edit. */
-  public static void deleteEdit(int id, AsyncCallback<JavaScriptObject> cb) {
-    edit(id).delete(cb);
+  public static void deleteEdit(
+      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
+    edit(project, id).delete(cb);
   }
 
   /** Publish change edit. */
-  public static void publishEdit(int id, AsyncCallback<JavaScriptObject> cb) {
+  public static void publishEdit(
+      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
     JavaScriptObject in = JavaScriptObject.createObject();
-    change(id).view("edit:publish").post(in, cb);
+    change(project, id).view("edit:publish").post(in, cb);
   }
 
   /** Rebase change edit on latest patch set. */
-  public static void rebaseEdit(int id, AsyncCallback<JavaScriptObject> cb) {
+  public static void rebaseEdit(
+      @Nullable String project, int id, AsyncCallback<JavaScriptObject> cb) {
     JavaScriptObject in = JavaScriptObject.createObject();
-    change(id).view("edit:rebase").post(in, cb);
+    change(project, id).view("edit:rebase").post(in, cb);
   }
 
   /** Rebase a revision onto the branch tip or another change. */
-  public static void rebase(int id, String commit, String base, AsyncCallback<ChangeInfo> cb) {
+  public static void rebase(
+      @Nullable String project, int id, String commit, String base, AsyncCallback<ChangeInfo> cb) {
     RebaseInput rebaseInput = RebaseInput.create();
     rebaseInput.setBase(base);
-    call(id, commit, "rebase").post(rebaseInput, cb);
+    call(project, id, commit, "rebase").post(rebaseInput, cb);
   }
 
   private static class MessageInput extends JavaScriptObject {
@@ -304,6 +326,8 @@
 
     public final native void baseChange(String b) /*-{ if(b)this.base_change=b; }-*/;
 
+    public final native void workInProgress(Boolean b) /*-{ if(b)this.work_in_progress=b; }-*/;
+
     protected CreateChangeInput() {}
   }
 
@@ -319,6 +343,28 @@
     protected CherryPickInput() {}
   }
 
+  private static class MoveInput extends JavaScriptObject {
+    static MoveInput create() {
+      return (MoveInput) createObject();
+    }
+
+    final native void setDestinationBranch(String d) /*-{ this.destination_branch = d; }-*/;
+
+    final native void setMessage(String m) /*-{ this.message = m; }-*/;
+
+    protected MoveInput() {}
+  }
+
+  private static class PrivateInput extends JavaScriptObject {
+    static PrivateInput create() {
+      return (PrivateInput) createObject();
+    }
+
+    final native void setMessage(String m) /*-{ this.message = m; }-*/;
+
+    protected PrivateInput() {}
+  }
+
   private static class RebaseInput extends JavaScriptObject {
     final native void setBase(String b) /*-{ this.base = b; }-*/;
 
@@ -329,24 +375,27 @@
     protected RebaseInput() {}
   }
 
-  private static RestApi call(int id, String action) {
-    return change(id).view(action);
+  private static RestApi call(@Nullable String project, int id, String action) {
+    return change(project, id).view(action);
   }
 
-  private static RestApi call(int id, String commit, String action) {
-    return change(id).view("revisions").id(commit).view(action);
+  private static RestApi call(@Nullable String project, int id, String commit, String action) {
+    return change(project, id).view("revisions").id(commit).view(action);
   }
 
-  public static RestApi change(int id) {
-    // TODO Switch to triplet project~branch~id format in URI.
-    return new RestApi("/changes/").id(String.valueOf(id));
+  public static RestApi change(@Nullable String project, int id) {
+    if (project == null) {
+      return new RestApi("/changes/").id(String.valueOf(id));
+    }
+    return new RestApi("/changes/").id(project, id);
   }
 
   public static String emptyToNull(String str) {
     return str == null || str.isEmpty() ? null : str;
   }
 
-  public static void commitWithLinks(int changeId, String revision, Callback<CommitInfo> callback) {
-    revision(changeId, revision).view("commit").addParameterTrue("links").get(callback);
+  public static void commitWithLinks(
+      @Nullable String project, int changeId, String revision, Callback<CommitInfo> callback) {
+    revision(project, changeId, revision).view("commit").addParameterTrue("links").get(callback);
   }
 }
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 ae64ac0..aa6c4ec 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,12 +33,18 @@
 
   String notCurrent();
 
+  String isPrivate();
+
+  String isWorkInProgress();
+
   String changeEdit();
 
   String myDashboardTitle();
 
   String unknownDashboardTitle();
 
+  String workInProgress();
+
   String incomingReviews();
 
   String outgoingReviews();
@@ -141,6 +147,14 @@
 
   String cherryPickTitle();
 
+  String moveChangeSend();
+
+  String headingMoveBranch();
+
+  String moveChangeMessage();
+
+  String moveTitle();
+
   String buttonRebaseChangeSend();
 
   String rebaseConfirmMessage();
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 01921de..2d5a9f9 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
@@ -7,9 +7,12 @@
 mergeConflict = Merge Conflict
 notCurrent = Not Current
 changeEdit = Change Edit
+isPrivate = (Private)
+isWorkInProgress = (Work in Progress)
 
 myDashboardTitle = My Reviews
 unknownDashboardTitle = Code Review Dashboard
+workInProgress Work in progress
 incomingReviews = Incoming reviews
 outgoingReviews = Outgoing reviews
 recentlyClosed = Recently closed
@@ -76,6 +79,11 @@
 cherryPickCommitMessage = Cherry Pick Commit Message:
 cherryPickTitle = Code Review - Cherry Pick Change to Another Branch
 
+headingMoveBranch = Move Change to Branch:
+moveChangeSend = Move Change
+moveChangeMessage = Move Change Message:
+moveTitle = Code Review - Move Change to Another Branch
+
 buttonRebaseChangeSend = Rebase
 rebaseConfirmMessage = Change parent revision
 rebaseNotPossibleMessage = Change is already up to date
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 0a7fd08..71b54f7d 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
@@ -20,81 +20,110 @@
 import com.google.gerrit.client.rpc.HttpCallback;
 import com.google.gerrit.client.rpc.NativeString;
 import com.google.gerrit.client.rpc.RestApi;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Patch;
 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.user.client.rpc.AsyncCallback;
 
 /** 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, boolean base, HttpCallback<NativeString> cb) {
+  public static void get(
+      @Nullable Project.NameKey project,
+      PatchSet.Id id,
+      String path,
+      boolean base,
+      HttpCallback<NativeString> cb) {
     RestApi api;
     if (id.get() != 0) {
       // Read from a published revision, when change edit doesn't
       // exist for the caller, or is not currently active.
-      api = ChangeApi.revision(id).view("files").id(path).view("content");
+      api =
+          ChangeApi.revision(Project.NameKey.asStringOrNull(project), id)
+              .view("files")
+              .id(path)
+              .view("content");
     } else if (Patch.COMMIT_MSG.equals(path)) {
-      api = editMessage(id.getParentKey().get()).addParameter("base", base);
+      api =
+          editMessage(Project.NameKey.asStringOrNull(project), id.getParentKey().get())
+              .addParameter("base", base);
     } else {
-      api = editFile(id.getParentKey().get(), path).addParameter("base", base);
+      api =
+          editFile(Project.NameKey.asStringOrNull(project), 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);
+  public static void get(
+      @Nullable Project.NameKey project,
+      PatchSet.Id id,
+      String path,
+      HttpCallback<NativeString> cb) {
+    get(project, id, path, false, cb);
   }
 
   /** Get meta info for change edit. */
-  public static void getMeta(PatchSet.Id id, String path, AsyncCallback<EditFileInfo> cb) {
+  public static void getMeta(
+      @Nullable String project, PatchSet.Id id, String path, AsyncCallback<EditFileInfo> cb) {
     if (id.get() != 0) {
       throw new IllegalStateException("only supported for edits");
     }
-    editFile(id.getParentKey().get(), path).view("meta").get(cb);
+    editFile(project, id.getParentKey().get(), path).view("meta").get(cb);
   }
 
   /** Put message into a change edit. */
-  public static void putMessage(int id, String m, GerritCallback<VoidResult> cb) {
-    editMessage(id).put(m, cb);
+  public static void putMessage(
+      @Nullable String project, int id, String m, GerritCallback<VoidResult> cb) {
+    editMessage(project, id).put(m, cb);
   }
 
   /** Put contents into a file or commit message in a change edit. */
-  public static void put(int id, String path, String content, GerritCallback<VoidResult> cb) {
+  public static void put(
+      @Nullable String project,
+      int id,
+      String path,
+      String content,
+      GerritCallback<VoidResult> cb) {
     if (Patch.COMMIT_MSG.equals(path)) {
-      putMessage(id, content, cb);
+      putMessage(project, id, content, cb);
     } else {
-      editFile(id, path).put(content, cb);
+      editFile(project, id, path).put(content, cb);
     }
   }
 
   /** Delete a file in the pending edit. */
-  public static void delete(int id, String path, AsyncCallback<VoidResult> cb) {
-    editFile(id, path).delete(cb);
+  public static void delete(
+      @Nullable String project, int id, String path, AsyncCallback<VoidResult> cb) {
+    editFile(project, id, path).delete(cb);
   }
 
   /** Rename a file in the pending edit. */
-  public static void rename(int id, String path, String newPath, AsyncCallback<VoidResult> cb) {
+  public static void rename(
+      @Nullable String project, int id, String path, String newPath, AsyncCallback<VoidResult> cb) {
     Input in = Input.create();
     in.oldPath(path);
     in.newPath(newPath);
-    ChangeApi.edit(id).post(in, cb);
+    ChangeApi.edit(project, id).post(in, cb);
   }
 
   /** Restore (undo delete/modify) a file in the pending edit. */
-  public static void restore(int id, String path, AsyncCallback<VoidResult> cb) {
+  public static void restore(
+      @Nullable String project, int id, String path, AsyncCallback<VoidResult> cb) {
     Input in = Input.create();
     in.restorePath(path);
-    ChangeApi.edit(id).post(in, cb);
+    ChangeApi.edit(project, id).post(in, cb);
   }
 
-  private static RestApi editMessage(int id) {
-    return ChangeApi.change(id).view("edit:message");
+  private static RestApi editMessage(@Nullable String project, int id) {
+    return ChangeApi.change(project, id).view("edit:message");
   }
 
-  private static RestApi editFile(int id, String path) {
-    return ChangeApi.edit(id).id(path);
+  private static RestApi editFile(@Nullable String project, int id, String path) {
+    return ChangeApi.edit(project, id).id(path);
   }
 
   private static class Input extends JavaScriptObject {
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 adf7cff..b9363cc 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
@@ -114,7 +114,7 @@
     table.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             final Cell cell = table.getCellForEvent(event);
             if (cell == null) {
               return;
@@ -133,18 +133,17 @@
   }
 
   @Override
-  protected Object getRowItemKey(final ChangeInfo item) {
+  protected Object getRowItemKey(ChangeInfo item) {
     return item.legacyId();
   }
 
   @Override
-  protected void onOpenRow(final int row) {
+  protected void onOpenRow(int row) {
     final ChangeInfo c = getRowItem(row);
-    final Change.Id id = c.legacyId();
-    Gerrit.display(PageLinks.toChange(id));
+    Gerrit.display(PageLinks.toChange(c.projectNameKey(), c.legacyId()));
   }
 
-  private void insertNoneRow(final int row) {
+  private void insertNoneRow(int row) {
     insertRow(row);
     table.setText(row, 0, Util.C.changeTableNone());
     final FlexCellFormatter fmt = table.getFlexCellFormatter();
@@ -152,13 +151,13 @@
     fmt.setStyleName(row, 0, Gerrit.RESOURCES.css().emptySection());
   }
 
-  private void insertChangeRow(final int row) {
+  private void insertChangeRow(int row) {
     insertRow(row);
     applyDataRowStyle(row);
   }
 
   @Override
-  protected void applyDataRowStyle(final int row) {
+  protected void applyDataRowStyle(int row) {
     super.applyDataRowStyle(row);
     final CellFormatter fmt = table.getCellFormatter();
     fmt.addStyleName(row, C_STAR, Gerrit.RESOURCES.css().iconCell());
@@ -225,7 +224,7 @@
     }
   }
 
-  private void populateChangeRow(final int row, final ChangeInfo c, boolean highlightUnreviewed) {
+  private void populateChangeRow(int row, ChangeInfo c, boolean highlightUnreviewed) {
     CellFormatter fmt = table.getCellFormatter();
     if (Gerrit.isSignedIn()) {
       table.setWidget(row, C_STAR, StarredChanges.createIcon(c.legacyId(), c.starred()));
@@ -237,9 +236,22 @@
 
     Change.Status status = c.status();
     if (status != Change.Status.NEW) {
-      table.setText(row, C_STATUS, Util.toLongString(status));
+      table.setText(
+          row,
+          C_STATUS,
+          Util.toLongString(status) + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
+    } else if (c.isWorkInProgress()) {
+      table.setText(
+          row,
+          C_STATUS,
+          Util.C.workInProgress() + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
     } else if (!c.mergeable()) {
-      table.setText(row, C_STATUS, Util.C.changeTableNotMergeable());
+      table.setText(
+          row,
+          C_STATUS,
+          Util.C.changeTableNotMergeable() + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
+    } else if (c.isPrivate()) {
+      table.setText(row, C_STATUS, Util.C.isPrivate());
     }
 
     if (c.owner() != null) {
@@ -408,7 +420,7 @@
     return hex.length() == 1 ? "0" + hex : hex;
   }
 
-  public void addSection(final Section s) {
+  public void addSection(Section s) {
     assert s.parent == null;
 
     s.parent = this;
@@ -426,8 +438,8 @@
     sections.add(s);
   }
 
-  private int insertRow(final int beforeRow) {
-    for (final Section s : sections) {
+  private int insertRow(int beforeRow) {
+    for (Section s : sections) {
       if (beforeRow <= s.titleRow) {
         s.titleRow++;
       }
@@ -438,8 +450,8 @@
     return table.insertRow(beforeRow);
   }
 
-  private void removeRow(final int row) {
-    for (final Section s : sections) {
+  private void removeRow(int row) {
+    for (Section s : sections) {
       if (row < s.titleRow) {
         s.titleRow--;
       }
@@ -456,7 +468,7 @@
     }
 
     @Override
-    public void onKeyPress(final KeyPressEvent event) {
+    public void onKeyPress(KeyPressEvent event) {
       int row = getCurrentRow();
       ChangeInfo c = getRowItem(row);
       if (c != null && Gerrit.isSignedIn()) {
@@ -466,8 +478,8 @@
   }
 
   private final class TableChangeLink extends ChangeLink {
-    private TableChangeLink(final String text, final ChangeInfo c) {
-      super(text, c.legacyId());
+    private TableChangeLink(String text, ChangeInfo c) {
+      super(c.projectNameKey(), c.legacyId(), text);
     }
 
     @Override
@@ -490,7 +502,7 @@
       this.highlightUnreviewed = value;
     }
 
-    public void setTitleText(final String text) {
+    public void setTitleText(String text) {
       titleText = text;
       titleWidget = null;
       if (titleRow >= 0) {
@@ -498,7 +510,7 @@
       }
     }
 
-    public void setTitleWidget(final Widget title) {
+    public void setTitleWidget(Widget title) {
       titleWidget = title;
       titleText = null;
       if (titleRow >= 0) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
index 0950fa5..987b382 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentApi.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.RestApi;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
@@ -23,39 +24,53 @@
 
 public class CommentApi {
 
-  public static void comments(PatchSet.Id id, AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
-    revision(id, "comments").get(cb);
+  public static void comments(
+      @Nullable String project, PatchSet.Id id, AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
+    revision(project, id, "comments").get(cb);
   }
 
-  public static void comment(PatchSet.Id id, String commentId, AsyncCallback<CommentInfo> cb) {
-    revision(id, "comments").id(commentId).get(cb);
+  public static void comment(
+      @Nullable String project, PatchSet.Id id, String commentId, AsyncCallback<CommentInfo> cb) {
+    revision(project, id, "comments").id(commentId).get(cb);
   }
 
-  public static void drafts(PatchSet.Id id, AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
-    revision(id, "drafts").get(cb);
+  public static void drafts(
+      @Nullable String project, PatchSet.Id id, AsyncCallback<NativeMap<JsArray<CommentInfo>>> cb) {
+    revision(project, id, "drafts").get(cb);
   }
 
-  public static void draft(PatchSet.Id id, String draftId, AsyncCallback<CommentInfo> cb) {
-    revision(id, "drafts").id(draftId).get(cb);
+  public static void draft(
+      @Nullable String project, PatchSet.Id id, String draftId, AsyncCallback<CommentInfo> cb) {
+    revision(project, id, "drafts").id(draftId).get(cb);
   }
 
   public static void createDraft(
-      PatchSet.Id id, CommentInfo content, AsyncCallback<CommentInfo> cb) {
-    revision(id, "drafts").put(content, cb);
+      @Nullable String project,
+      PatchSet.Id id,
+      CommentInfo content,
+      AsyncCallback<CommentInfo> cb) {
+    revision(project, id, "drafts").put(content, cb);
   }
 
   public static void updateDraft(
-      PatchSet.Id id, String draftId, CommentInfo content, AsyncCallback<CommentInfo> cb) {
-    revision(id, "drafts").id(draftId).put(content, cb);
+      @Nullable String project,
+      PatchSet.Id id,
+      String draftId,
+      CommentInfo content,
+      AsyncCallback<CommentInfo> cb) {
+    revision(project, id, "drafts").id(draftId).put(content, cb);
   }
 
   public static void deleteDraft(
-      PatchSet.Id id, String draftId, AsyncCallback<JavaScriptObject> cb) {
-    revision(id, "drafts").id(draftId).delete(cb);
+      @Nullable String project,
+      PatchSet.Id id,
+      String draftId,
+      AsyncCallback<JavaScriptObject> cb) {
+    revision(project, id, "drafts").id(draftId).delete(cb);
   }
 
-  private static RestApi revision(PatchSet.Id id, String type) {
-    return ChangeApi.revision(id).view(type);
+  private static RestApi revision(@Nullable String project, PatchSet.Id id, String type) {
+    return ChangeApi.revision(project, id).view(type);
   }
 
   private CommentApi() {}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java
index 3cfe63d..aba4ee0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/DashboardTable.java
@@ -34,7 +34,7 @@
   private List<String> titles;
   private List<String> queries;
 
-  public DashboardTable(final Screen screen, String params) {
+  public DashboardTable(Screen screen, String params) {
     titles = new ArrayList<>();
     queries = new ArrayList<>();
     String foreach = null;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java
index 370d942..1695eb9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PagedSingleListScreen.java
@@ -64,7 +64,7 @@
             keysNavigation.add(
                 new KeyCommand(0, 'R', Util.C.keyReloadSearch()) {
                   @Override
-                  public void onKeyPress(final KeyPressEvent event) {
+                  public void onKeyPress(KeyPressEvent event) {
                     Gerrit.display(getToken());
                   }
                 });
@@ -126,7 +126,7 @@
     }
 
     @Override
-    public void onKeyPress(final KeyPressEvent event) {
+    public void onKeyPress(KeyPressEvent event) {
       if (link.isVisible()) {
         History.newItem(link.getTargetHistoryToken());
       }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java
index 12638d7..f511308 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ProjectDashboardScreen.java
@@ -25,7 +25,7 @@
   private DashboardTable table;
   private String params;
 
-  public ProjectDashboardScreen(final Project.NameKey toShow, String params) {
+  public ProjectDashboardScreen(Project.NameKey toShow, String params) {
     super(toShow);
     this.params = params;
   }
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 696fe8b..8d580a3 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
@@ -65,7 +65,7 @@
           if (result.length() == 1 && isSingleQuery(query)) {
             ChangeInfo c = result.get(0);
             Change.Id id = c.legacyId();
-            Gerrit.display(PageLinks.toChange(id));
+            Gerrit.display(PageLinks.toChange(c.projectNameKey(), id));
           } else {
             display(result);
             QueryScreen.this.display();
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 113651b..f851d5e 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
@@ -60,7 +60,6 @@
 
   private native void init() /*-{
     this.labels = {};
-    this.strict_labels = true;
   }-*/;
 
   public final native void prePost() /*-{
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java
index 0b83119..fde2b05 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/RevisionInfoCache.java
@@ -35,10 +35,11 @@
 
   private final LinkedHashMap<PatchSet.Id, String> psToCommit;
 
-  @SuppressWarnings("serial")
   private RevisionInfoCache() {
     psToCommit =
         new LinkedHashMap<PatchSet.Id, String>(LIMIT) {
+          private static final long serialVersionUID = 1L;
+
           @Override
           protected boolean removeEldestEntry(Map.Entry<PatchSet.Id, String> e) {
             return size() > LIMIT;
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 b4499ac..b1028420 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
@@ -73,7 +73,7 @@
   }
 
   /** Make a key command that toggles the star for a change. */
-  public static KeyCommand newKeyCommand(final Icon icon) {
+  public static KeyCommand newKeyCommand(Icon icon) {
     return new KeyCommand(0, 's', Util.C.changeTableStar()) {
       @Override
       public void onKeyPress(KeyPressEvent event) {
@@ -99,7 +99,7 @@
    * Set the starred status of a change. This method broadcasts to all interested UI widgets and
    * sends an RPC to the server to record the updated status.
    */
-  public static void toggleStar(final Change.Id changeId, final boolean newValue) {
+  public static void toggleStar(Change.Id changeId, boolean newValue) {
     pending.put(changeId, newValue);
     fireChangeStarEvent(changeId, newValue);
     if (!busy) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
index b2efcdb..8d949d1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
@@ -25,13 +25,11 @@
   private static final String SUBJECT_CROP_APPENDIX = "...";
   private static final int SUBJECT_CROP_RANGE = 10;
 
-  public static String toLongString(final Change.Status status) {
+  public static String toLongString(Change.Status status) {
     if (status == null) {
       return "";
     }
     switch (status) {
-      case DRAFT:
-        return C.statusLongDraft();
       case NEW:
         return C.statusLongNew();
       case MERGED:
@@ -62,7 +60,7 @@
    * @return the subject, cropped if needed
    */
   @SuppressWarnings("deprecation")
-  public static String cropSubject(final String subject) {
+  public static String cropSubject(String subject) {
     if (subject.length() > SUBJECT_MAX_LENGTH) {
       final int maxLength = SUBJECT_MAX_LENGTH - SUBJECT_CROP_APPENDIX.length();
       for (int cropPosition = maxLength;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java
index 0d49677..5c6b51a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/dashboards/DashboardInfo.java
@@ -35,7 +35,13 @@
 
   public final native String url() /*-{ return this.url; }-*/;
 
-  public final native boolean isDefault() /*-{ return this['default'] ? true : false; }-*/;
+  private final native boolean isDefaultLegacy() /*-{ return this['default'] ? true : false; }-*/;
+
+  private final native boolean isDefaultNew() /*-{ return this.is_default ? true : false; }-*/;
+
+  public final boolean isDefault() {
+    return isDefaultLegacy() || isDefaultNew();
+  }
 
   protected DashboardInfo() {}
 }
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 6215854..0e4ef4e 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
@@ -34,7 +34,7 @@
 public class DashboardsTable extends NavigationTable<DashboardInfo> {
   Project.NameKey project;
 
-  public DashboardsTable(final Project.NameKey project) {
+  public DashboardsTable(Project.NameKey project) {
     super(Util.C.dashboardItem());
     this.project = project;
     initColumnHeaders();
@@ -96,7 +96,7 @@
     finishDisplay();
   }
 
-  protected void insertTitleRow(final int row, String section) {
+  protected void insertTitleRow(int row, String section) {
     table.insertRow(row);
 
     table.setText(row, 0, section);
@@ -106,7 +106,7 @@
     fmt.addStyleName(row, 0, Gerrit.RESOURCES.css().sectionHeader());
   }
 
-  protected void insert(final int row, final DashboardInfo k) {
+  protected void insert(int row, DashboardInfo k) {
     table.insertRow(row);
 
     applyDataRowStyle(row);
@@ -121,7 +121,7 @@
     populate(row, k);
   }
 
-  protected void populate(final int row, final DashboardInfo k) {
+  protected void populate(int row, DashboardInfo k) {
     if (k.isDefault()) {
       table.setWidget(row, 1, new Image(Gerrit.RESOURCES.greenCheck()));
       final FlexCellFormatter fmt = table.getFlexCellFormatter();
@@ -147,12 +147,12 @@
   }
 
   @Override
-  protected Object getRowItemKey(final DashboardInfo item) {
+  protected Object getRowItemKey(DashboardInfo item) {
     return item.id();
   }
 
   @Override
-  protected void onOpenRow(final int row) {
+  protected void onOpenRow(int row) {
     if (row > 0) {
       movePointerTo(row);
     }
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 953bc87..0091f53 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
@@ -68,23 +68,15 @@
     colorLines(cm, LineClassWhere.WRAP, color, line, line + cnt);
   }
 
-  void colorLines(
-      final CodeMirror cm,
-      final LineClassWhere where,
-      final String className,
-      final int start,
-      final int end) {
+  void colorLines(CodeMirror cm, LineClassWhere where, String className, int start, int end) {
     if (start < end) {
       for (int line = start; line < end; line++) {
         cm.addLineClass(line, where, className);
       }
       undo.add(
-          new Runnable() {
-            @Override
-            public void run() {
-              for (int line = start; line < end; line++) {
-                cm.removeLineClass(line, where, className);
-              }
+          () -> {
+            for (int line = start; line < end; line++) {
+              cm.removeLineClass(line, where, className);
             }
           });
     }
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 587dacc..ef1ec1e 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
@@ -21,8 +21,10 @@
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JsArray;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -39,6 +41,7 @@
 
 /** Tracks comment widgets for {@link DiffScreen}. */
 abstract class CommentManager {
+  @Nullable private final Project.NameKey project;
   private final DiffObject base;
   private final PatchSet.Id revision;
   private final String path;
@@ -54,12 +57,14 @@
 
   CommentManager(
       DiffScreen host,
+      @Nullable Project.NameKey project,
       DiffObject base,
       PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
       boolean open) {
     this.host = host;
+    this.project = project;
     this.base = base;
     this.revision = revision;
     this.path = path;
@@ -203,32 +208,26 @@
 
   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);
+  Runnable signInCallback(CodeMirror cm) {
+    return () -> {
+      String token = host.getToken();
+      if (cm.extras().hasActiveLine()) {
+        token += "@" + getTokenSuffixForActiveLine(cm);
       }
+      Gerrit.doSignIn(token);
     };
   }
 
   abstract void newDraft(CodeMirror cm);
 
-  Runnable newDraftCallback(final CodeMirror cm) {
+  Runnable newDraftCallback(CodeMirror cm) {
     if (!Gerrit.isSignedIn()) {
       return signInCallback(cm);
     }
 
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.extras().hasActiveLine()) {
-          newDraft(cm);
-        }
+    return () -> {
+      if (cm.extras().hasActiveLine()) {
+        newDraft(cm);
       }
     };
   }
@@ -238,7 +237,12 @@
     CommentGroup group = group(side, cmLinePlusOne);
     DraftBox box =
         new DraftBox(
-            group, getCommentLinkProcessor(), getPatchSetIdFromSide(side), info, isExpandAll());
+            group,
+            getCommentLinkProcessor(),
+            project,
+            getPatchSetIdFromSide(side),
+            info,
+            isExpandAll());
 
     if (info.inReplyTo() != null) {
       PublishedBox r = getPublished().get(info.inReplyTo());
@@ -267,52 +271,49 @@
 
   abstract SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side);
 
-  Runnable commentNav(final CodeMirror src, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // 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 = getMapForNav(src.side());
-        int line =
-            src.extras().hasActiveLine() ? src.getLineNumber(src.extras().activeLine()) + 1 : 0;
+  Runnable commentNav(CodeMirror src, Direction dir) {
+    return () -> {
+      // 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 = 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);
+      CommentGroup g;
+      if (dir == Direction.NEXT) {
+        map = map.tailMap(line + 1);
+        if (map.isEmpty()) {
+          return;
+        }
+        g = map.get(map.firstKey());
+        while (g.getBoxCount() == 0) {
+          map = map.tailMap(map.firstKey() + 1);
           if (map.isEmpty()) {
             return;
           }
           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);
+        }
+      } else {
+        map = map.headMap(line);
+        if (map.isEmpty()) {
+          return;
+        }
+        g = map.get(map.lastKey());
+        while (g.getBoxCount() == 0) {
+          map = map.headMap(map.lastKey());
           if (map.isEmpty()) {
             return;
           }
           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();
-        double y = cm.heightAtLine(g.getLine() - 1, "local");
-        cm.setCursor(Pos.create(g.getLine() - 1));
-        cm.scrollToY(y - 0.5 * cm.scrollbarV().getClientHeight());
-        cm.focus();
       }
+
+      CodeMirror cm = g.getCm();
+      double y = cm.heightAtLine(g.getLine() - 1, "local");
+      cm.setCursor(Pos.create(g.getLine() - 1));
+      cm.scrollToY(y - 0.5 * cm.scrollbarV().getClientHeight());
+      cm.focus();
     };
   }
 
@@ -359,6 +360,7 @@
             new PublishedBox(
                 group,
                 getCommentLinkProcessor(),
+                project,
                 getPatchSetIdFromSide(side),
                 info,
                 side,
@@ -425,26 +427,20 @@
 
   abstract CommentGroup getCommentGroupOnActiveLine(CodeMirror cm);
 
-  Runnable toggleOpenBox(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CommentGroup group = getCommentGroupOnActiveLine(cm);
-        if (group != null) {
-          group.openCloseLast();
-        }
+  Runnable toggleOpenBox(CodeMirror cm) {
+    return () -> {
+      CommentGroup group = getCommentGroupOnActiveLine(cm);
+      if (group != null) {
+        group.openCloseLast();
       }
     };
   }
 
-  Runnable openCloseAll(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CommentGroup group = getCommentGroupOnActiveLine(cm);
-        if (group != null) {
-          group.openCloseAll();
-        }
+  Runnable openCloseAll(CodeMirror cm) {
+    return () -> {
+      CommentGroup group = getCommentGroupOnActiveLine(cm);
+      if (group != null) {
+        group.openCloseAll();
       }
     };
   }
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 3ed0c50..533b745 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
@@ -21,8 +21,10 @@
 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.common.Nullable;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import java.util.Collections;
@@ -30,6 +32,7 @@
 
 /** Collection of published and draft comments loaded from the server. */
 class CommentsCollections {
+  @Nullable private final Project.NameKey project;
   private final String path;
   private final DiffObject base;
   private final PatchSet.Id revision;
@@ -40,7 +43,9 @@
   JsArray<CommentInfo> draftsBase;
   JsArray<CommentInfo> draftsRevision;
 
-  CommentsCollections(DiffObject base, PatchSet.Id revision, String path) {
+  CommentsCollections(
+      @Nullable Project.NameKey project, DiffObject base, PatchSet.Id revision, String path) {
+    this.project = project;
     this.path = path;
     this.base = base;
     this.revision = revision;
@@ -48,15 +53,19 @@
 
   void load(CallbackGroup group) {
     if (base.isPatchSet()) {
-      CommentApi.comments(base.asPatchSetId(), group.add(publishedBase()));
+      CommentApi.comments(
+          Project.NameKey.asStringOrNull(project), base.asPatchSetId(), group.add(publishedBase()));
     }
-    CommentApi.comments(revision, group.add(publishedRevision()));
+    CommentApi.comments(
+        Project.NameKey.asStringOrNull(project), revision, group.add(publishedRevision()));
 
     if (Gerrit.isSignedIn()) {
       if (base.isPatchSet()) {
-        CommentApi.drafts(base.asPatchSetId(), group.add(draftsBase()));
+        CommentApi.drafts(
+            Project.NameKey.asStringOrNull(project), base.asPatchSetId(), group.add(draftsBase()));
       }
-      CommentApi.drafts(revision, group.add(draftsRevision()));
+      CommentApi.drafts(
+          Project.NameKey.asStringOrNull(project), revision, group.add(draftsRevision()));
     }
   }
 
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 3f64066..1815920 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
@@ -21,14 +21,19 @@
 import com.google.gerrit.client.info.FileInfo;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.RestApi;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
 public class DiffApi {
   public static void list(
-      int id, String revision, RevisionInfo base, AsyncCallback<NativeMap<FileInfo>> cb) {
-    RestApi api = ChangeApi.revision(id, revision).view("files");
+      @Nullable String project,
+      int id,
+      String revision,
+      RevisionInfo base,
+      AsyncCallback<NativeMap<FileInfo>> cb) {
+    RestApi api = ChangeApi.revision(project, id, revision).view("files");
     if (base != null) {
       if (base._number() < 0) {
         api.addParameter("parent", -base._number());
@@ -39,8 +44,12 @@
     api.get(NativeMap.copyKeysIntoChildren("path", cb));
   }
 
-  public static void list(PatchSet.Id id, PatchSet.Id base, AsyncCallback<NativeMap<FileInfo>> cb) {
-    RestApi api = ChangeApi.revision(id).view("files");
+  public static void list(
+      @Nullable String project,
+      PatchSet.Id id,
+      PatchSet.Id base,
+      AsyncCallback<NativeMap<FileInfo>> cb) {
+    RestApi api = ChangeApi.revision(project, id).view("files");
     if (base != null) {
       if (base.get() < 0) {
         api.addParameter("parent", -base.get());
@@ -51,8 +60,8 @@
     api.get(NativeMap.copyKeysIntoChildren("path", cb));
   }
 
-  public static DiffApi diff(PatchSet.Id id, String path) {
-    return new DiffApi(ChangeApi.revision(id).view("files").id(path).view("diff"));
+  public static DiffApi diff(@Nullable String project, PatchSet.Id id, String path) {
+    return new DiffApi(ChangeApi.revision(project, id).view("files").id(path).view("diff"));
   }
 
   private final RestApi call;
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
index 60a75eb..b4221ca 100644
--- 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
@@ -39,12 +39,14 @@
 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.Nullable;
 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.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.Scheduler;
 import com.google.gwt.core.client.Scheduler.RepeatingCommand;
@@ -94,6 +96,7 @@
     }
   }
 
+  @Nullable private Project.NameKey project;
   private final Change.Id changeId;
   final DiffObject base;
   final PatchSet.Id revision;
@@ -122,12 +125,14 @@
   Header header;
 
   DiffScreen(
+      @Nullable Project.NameKey project,
       DiffObject base,
       DiffObject revision,
       String path,
       DisplaySide startSide,
       int startLine,
       DiffView diffScreenType) {
+    this.project = project;
     this.base = base;
     this.revision = revision.asPatchSetId();
     this.changeId = revision.asPatchSetId().getParentKey();
@@ -138,7 +143,7 @@
     prefs = DiffPreferences.create(Gerrit.getDiffPreferences());
     handlers = new ArrayList<>(6);
     keysNavigation = new KeyCommandSet(Gerrit.C.sectionNavigation());
-    header = new Header(keysNavigation, base, revision, path, diffScreenType, prefs);
+    header = new Header(keysNavigation, project, base, revision, path, diffScreenType, prefs);
     skipManager = new SkipManager(this);
   }
 
@@ -171,7 +176,7 @@
               public void onFailure(Throwable caught) {}
             }));
 
-    DiffApi.diff(revision, path)
+    DiffApi.diff(Project.NameKey.asStringOrNull(project), revision, path)
         .base(base.asPatchSetId())
         .wholeFile()
         .intraline(prefs.intralineDifference())
@@ -200,6 +205,7 @@
 
     if (Gerrit.isSignedIn()) {
       ChangeApi.edit(
+          Project.NameKey.asStringOrNull(project),
           changeId.get(),
           group2.add(
               new AsyncCallback<EditInfo>() {
@@ -213,12 +219,12 @@
               }));
     }
 
-    final CommentsCollections comments = new CommentsCollections(base, revision, path);
+    final CommentsCollections comments = new CommentsCollections(project, base, revision, path);
     comments.load(group2);
 
     countParents(group2);
 
-    RestApi call = ChangeApi.detail(changeId.get());
+    RestApi call = ChangeApi.detail(Project.NameKey.asStringOrNull(project), changeId.get());
     ChangeList.addOptions(call, EnumSet.of(ListChangesOption.ALL_REVISIONS));
     call.get(
         group2.add(
@@ -226,6 +232,7 @@
               @Override
               public void onSuccess(ChangeInfo info) {
                 changeStatus = info.status();
+                project = info.projectNameKey();
                 info.revisions().copyKeysIntoChildren("name");
                 if (edit != null) {
                   edit.setName(edit.commit().commit());
@@ -259,7 +266,7 @@
   }
 
   private void countParents(CallbackGroup cbg) {
-    ChangeApi.revision(changeId.get(), revision.getId())
+    ChangeApi.revision(Project.NameKey.asStringOrNull(project), changeId.get(), revision.getId())
         .view("commit")
         .get(
             cbg.add(
@@ -336,7 +343,7 @@
     handlers.clear();
   }
 
-  void registerCmEvents(final CodeMirror cm) {
+  void registerCmEvents(CodeMirror cm) {
     cm.on("cursorActivity", updateActiveLine(cm));
     cm.on("focus", updateActiveLine(cm));
     KeyMap keyMap =
@@ -356,170 +363,44 @@
             .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;
-                    }
+                () -> {
+                  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("','", prefsAction::show)
+            .on("Shift-/", () -> new ShowHelpCommand().onKeyPress(null))
+            .on("Space", () -> cm.vim().handleKey("<C-d>"))
+            .on("Shift-Space", () -> cm.vim().handleKey("<C-u>"))
+            .on("Ctrl-F", () -> cm.execCommand("find"))
+            .on("Ctrl-G", () -> 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("Shift-Ctrl-G", () -> cm.execCommand("findPrev"))
+            .on("Shift-Enter", () -> cm.execCommand("findPrev"))
             .on(
                 "Esc",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.setCursor(cm.getCursor());
-                    cm.execCommand("clearSearch");
-                    cm.vim().handleEx("nohlsearch");
-                  }
+                () -> {
+                  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"));
-                  }
-                });
+            .on("Ctrl-A", () -> cm.execCommand("selectAll"))
+            .on("G O", () -> Gerrit.display(PageLinks.toChangeQuery("status:open")))
+            .on("G M", () -> Gerrit.display(PageLinks.toChangeQuery("status:merged")))
+            .on("G A", () -> 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"));
-                }
-              });
+          .on("G I", () -> Gerrit.display(PageLinks.MINE))
+          .on("G C", () -> Gerrit.display(PageLinks.toChangeQuery("has:draft")))
+          .on("G W", () -> Gerrit.display(PageLinks.toChangeQuery("is:watched status:open")))
+          .on("G S", () -> Gerrit.display(PageLinks.toChangeQuery("is:starred")));
     }
 
     if (revision.get() != 0) {
@@ -537,7 +418,7 @@
     }
   }
 
-  private BeforeSelectionChangeHandler onSelectionChange(final CodeMirror cm) {
+  private BeforeSelectionChangeHandler onSelectionChange(CodeMirror cm) {
     return new BeforeSelectionChangeHandler() {
       private InsertCommentBubble bubble;
 
@@ -568,7 +449,7 @@
   public void registerKeys() {
     super.registerKeys();
 
-    keysNavigation.add(new UpToChangeCommand(revision, 0, 'u'));
+    keysNavigation.add(new UpToChangeCommand(project, revision, 0, 'u'));
     keysNavigation.add(
         new NoOpKeyCommand(0, 'j', PatchUtil.C.lineNext()),
         new NoOpKeyCommand(0, 'k', PatchUtil.C.linePrev()));
@@ -638,6 +519,11 @@
     }
   }
 
+  @Nullable
+  public Project.NameKey getProject() {
+    return project;
+  }
+
   void registerHandlers() {
     removeKeyHandlerRegistrations();
     handlers.add(GlobalKey.add(this, keysAction));
@@ -698,15 +584,12 @@
 
   abstract void setSyntaxHighlighting(boolean b);
 
-  void setContext(final int context) {
+  void setContext(int context) {
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            skipManager.removeAll();
-            skipManager.render(context, diff);
-            updateRenderEntireFile();
-          }
+        () -> {
+          skipManager.removeAll();
+          skipManager.render(context, diff);
+          updateRenderEntireFile();
         });
   }
 
@@ -753,21 +636,18 @@
     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);
-        }
+  private Runnable openEditScreen(CodeMirror cm) {
+    return () -> {
+      LineHandle handle = cm.extras().activeLine();
+      int line = cm.getLineNumber(handle) + 1;
+      if (Patch.COMMIT_MSG.equals(path)) {
+        line = adjustCommitMessageLine(line);
+      }
+      String token = Dispatcher.toEditScreen(project, revision, path, line);
+      if (!Gerrit.isSignedIn()) {
+        Gerrit.doSignIn(token);
+      } else {
+        Gerrit.display(token);
       }
     };
   }
@@ -832,63 +712,52 @@
 
   abstract void operation(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 rev = String.valueOf(revision.get());
-                Gerrit.display(
-                    PageLinks.toChange(changeId, base.asString(), rev),
-                    new ChangeScreen(changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
-              }
-            });
+  private Runnable upToChange(boolean openReplyBox) {
+    return () -> {
+      CallbackGroup group = new CallbackGroup();
+      getCommentManager().saveAllDrafts(group);
+      group.done();
+      group.addListener(
+          new GerritCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+              String rev = String.valueOf(revision.get());
+              Gerrit.display(
+                  PageLinks.toChange(project, changeId, base.asString(), rev),
+                  new ChangeScreen(
+                      project, changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
+            }
+          });
+    };
+  }
+
+  private Runnable maybePrevVimSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.vim().hasSearchHighlight()) {
+        cm.vim().handleKey("N");
+      } else {
+        getCommentManager().commentNav(cm, Direction.NEXT).run();
       }
     };
   }
 
-  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(CodeMirror cm) {
+    return () -> {
+      if (cm.vim().hasSearchHighlight()) {
+        cm.vim().handleKey("n");
+      } else {
+        getChunkManager().diffChunkNav(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();
-        }
+  Runnable maybeNextCmSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.hasSearchHighlight()) {
+        cm.execCommand("findNext");
+      } else {
+        cm.execCommand("clearSearch");
+        getCommentManager().toggleOpenBox(cm).run();
       }
     };
   }
@@ -951,7 +820,7 @@
   void prefetchNextFile() {
     String nextPath = header.getNextPath();
     if (nextPath != null) {
-      DiffApi.diff(revision, nextPath)
+      DiffApi.diff(Project.NameKey.asStringOrNull(project), revision, nextPath)
           .base(base.asPatchSetId())
           .wholeFile()
           .intraline(prefs.intralineDifference())
@@ -973,8 +842,8 @@
   }
 
   void reloadDiffInfo() {
-    final int id = ++reloadVersionId;
-    DiffApi.diff(revision, path)
+    int id = ++reloadVersionId;
+    DiffApi.diff(Project.NameKey.asStringOrNull(project), revision, path)
         .base(base.asPatchSetId())
         .wholeFile()
         .intraline(prefs.intralineDifference())
@@ -986,16 +855,13 @@
                 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);
-                        }
+                      () -> {
+                        skipManager.removeAll();
+                        getChunkManager().reset();
+                        getDiffTable().scrollbar.removeDiffAnnotations();
+                        setShowIntraline(prefs.intralineDifference());
+                        render(diff);
+                        skipManager.render(prefs.context(), diff);
                       });
                 }
               }
@@ -1017,11 +883,11 @@
 
   abstract Runnable updateActiveLine(CodeMirror cm);
 
-  private GutterClickHandler onGutterClick(final CodeMirror cm) {
+  private GutterClickHandler onGutterClick(CodeMirror cm) {
     return new GutterClickHandler() {
       @Override
       public void handle(
-          CodeMirror instance, final int line, final String gutterClass, NativeEvent clickEvent) {
+          CodeMirror instance, int line, String gutterClass, NativeEvent clickEvent) {
         if (Element.as(clickEvent.getEventTarget()).hasClassName(getLineNumberClassName())
             && clickEvent.getButton() == NativeEvent.BUTTON_LEFT
             && !clickEvent.getMetaKey()
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 4650acf..a91f8e6 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
@@ -73,10 +73,20 @@
   DiffTable(DiffScreen parent, DiffObject base, DiffObject revision, String path) {
     patchSetSelectBoxA =
         new PatchSetSelectBox(
-            parent, DisplaySide.A, revision.asPatchSetId().getParentKey(), base, path);
+            parent,
+            DisplaySide.A,
+            parent.getProject(),
+            revision.asPatchSetId().getParentKey(),
+            base,
+            path);
     patchSetSelectBoxB =
         new PatchSetSelectBox(
-            parent, DisplaySide.B, revision.asPatchSetId().getParentKey(), revision, path);
+            parent,
+            DisplaySide.B,
+            parent.getProject(),
+            revision.asPatchSetId().getParentKey(),
+            revision,
+            path);
     PatchSetSelectBox.link(patchSetSelectBoxA, patchSetSelectBoxB);
 
     this.scrollbar = new Scrollbar(this);
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 b86df0b..33d1ac4 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
@@ -22,7 +22,9 @@
 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.common.Nullable;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.Scheduler;
@@ -63,6 +65,7 @@
 
   private final CommentLinkProcessor linkProcessor;
   private final PatchSet.Id psId;
+  @Nullable private final Project.NameKey project;
   private final boolean expandAll;
   private CommentInfo comment;
   private PublishedBox replyToBox;
@@ -90,6 +93,7 @@
   DraftBox(
       CommentGroup group,
       CommentLinkProcessor clp,
+      @Nullable Project.NameKey pj,
       PatchSet.Id id,
       CommentInfo info,
       boolean expandAllComments) {
@@ -97,6 +101,7 @@
 
     linkProcessor = clp;
     psId = id;
+    project = pj;
     expandAll = expandAllComments;
     initWidget(uiBinder.createAndBindUi(this));
 
@@ -295,7 +300,7 @@
     enableEdit(false);
 
     pendingGroup = group;
-    final LocalComments lc = new LocalComments(psId);
+    final LocalComments lc = new LocalComments(project, psId);
     GerritCallback<CommentInfo> cb =
         new GerritCallback<CommentInfo>() {
           @Override
@@ -323,9 +328,10 @@
           }
         };
     if (input.id() == null) {
-      CommentApi.createDraft(psId, input, group.add(cb));
+      CommentApi.createDraft(Project.NameKey.asStringOrNull(project), psId, input, group.add(cb));
     } else {
-      CommentApi.updateDraft(psId, input.id(), input, group.add(cb));
+      CommentApi.updateDraft(
+          Project.NameKey.asStringOrNull(project), psId, input.id(), input, group.add(cb));
     }
     CodeMirror cm = getCm();
     cm.vim().handleKey("<Esc>");
@@ -364,6 +370,7 @@
       setEdit(false);
       pendingGroup = new CallbackGroup();
       CommentApi.deleteDraft(
+          Project.NameKey.asStringOrNull(project),
           psId,
           comment.id(),
           pendingGroup.addFinal(
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 a2ffb03f..7a97df1 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
@@ -32,11 +32,13 @@
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.client.ui.InlineHyperlink;
+import com.google.gerrit.common.Nullable;
 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.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.JsArrayString;
@@ -90,6 +92,7 @@
   @UiField Image preferences;
 
   private final KeyCommandSet keys;
+  @Nullable private final Project.NameKey projectKey;
   private final DiffObject base;
   private final PatchSet.Id patchSetId;
   private final String path;
@@ -104,6 +107,7 @@
 
   Header(
       KeyCommandSet keys,
+      @Nullable Project.NameKey project,
       DiffObject base,
       DiffObject patchSetId,
       String path,
@@ -111,6 +115,7 @@
       DiffPreferences prefs) {
     initWidget(uiBinder.createAndBindUi(this));
     this.keys = keys;
+    this.projectKey = project;
     this.base = base;
     this.patchSetId = patchSetId.asPatchSetId();
     this.path = path;
@@ -123,6 +128,7 @@
     SafeHtml.setInnerHTML(filePath, formatPath(path));
     up.setTargetHistoryToken(
         PageLinks.toChange(
+            project,
             patchSetId.asPatchSetId().getParentKey(),
             base.asString(),
             patchSetId.asPatchSetId().getId()));
@@ -158,6 +164,7 @@
   @Override
   protected void onLoad() {
     DiffApi.list(
+        Project.NameKey.asStringOrNull(projectKey),
         patchSetId,
         base.asPatchSetId(),
         new GerritCallback<NativeMap<FileInfo>>() {
@@ -172,7 +179,7 @@
         });
 
     if (Gerrit.isSignedIn()) {
-      ChangeApi.revision(patchSetId)
+      ChangeApi.revision(Project.NameKey.asStringOrNull(projectKey), patchSetId)
           .view("files")
           .addParameterTrue("reviewed")
           .get(
@@ -242,7 +249,10 @@
   }
 
   private RestApi reviewed() {
-    return ChangeApi.revision(patchSetId).view("files").id(path).view("reviewed");
+    return ChangeApi.revision(Project.NameKey.asStringOrNull(projectKey), patchSetId)
+        .view("files")
+        .id(path)
+        .view("reviewed");
   }
 
   @UiHandler("preferences")
@@ -252,8 +262,8 @@
 
   private String url(FileInfo info) {
     return diffScreenType == DiffView.UNIFIED_DIFF
-        ? Dispatcher.toUnified(base, patchSetId, info.path())
-        : Dispatcher.toSideBySide(base, patchSetId, info.path());
+        ? Dispatcher.toUnified(projectKey, base, patchSetId, info.path())
+        : Dispatcher.toSideBySide(projectKey, base, patchSetId, info.path());
   }
 
   private KeyCommand setupNav(InlineHyperlink link, char key, String help, FileInfo info) {
@@ -279,7 +289,7 @@
       return k;
     }
     link.getElement().getStyle().setVisibility(Visibility.HIDDEN);
-    keys.add(new UpToChangeCommand(patchSetId, 0, key));
+    keys.add(new UpToChangeCommand(projectKey, patchSetId, 0, key));
     return null;
   }
 
@@ -318,47 +328,26 @@
   }
 
   Runnable toggleReviewed() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        reviewed.setValue(!reviewed.getValue(), true);
-      }
-    };
+    return () -> reviewed.setValue(!reviewed.getValue(), true);
   }
 
   Runnable navigate(Direction dir) {
     switch (dir) {
       case PREV:
-        return new Runnable() {
-          @Override
-          public void run() {
-            (hasPrev ? prev : up).go();
-          }
-        };
+        return () -> (hasPrev ? prev : up).go();
       case NEXT:
-        return new Runnable() {
-          @Override
-          public void run() {
-            (hasNext ? next : up).go();
-          }
-        };
+        return () -> (hasNext ? next : up).go();
       default:
-        return new Runnable() {
-          @Override
-          public void run() {}
-        };
+        return () -> {};
     }
   }
 
   Runnable reviewedAndNext() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (Gerrit.isSignedIn()) {
-          reviewed.setValue(true, true);
-        }
-        navigate(Direction.NEXT).run();
+    return () -> {
+      if (Gerrit.isSignedIn()) {
+        reviewed.setValue(true, true);
       }
+      navigate(Direction.NEXT).run();
     };
   }
 
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 b04973a..f8eab91 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
@@ -35,7 +35,7 @@
 
   @UiField Image icon;
 
-  InsertCommentBubble(final CommentManager commentManager, final CodeMirror cm) {
+  InsertCommentBubble(CommentManager commentManager, CodeMirror cm) {
     initWidget(uiBinder.createAndBindUi(this));
     addDomHandler(
         new ClickHandler() {
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 822bc74..292773c 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
@@ -26,9 +26,11 @@
 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.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -61,17 +63,24 @@
   @UiField HTMLPanel linkPanel;
   @UiField BoxStyle style;
 
+  @Nullable private final Project.NameKey project;
+  private final Change.Id changeId;
+
   private DiffScreen parent;
   private DisplaySide side;
   private boolean sideA;
   private String path;
-  private Change.Id changeId;
   private PatchSet.Id revision;
   private DiffObject idActive;
   private PatchSetSelectBox other;
 
   PatchSetSelectBox(
-      DiffScreen parent, DisplaySide side, Change.Id changeId, DiffObject diffObject, String path) {
+      DiffScreen parent,
+      DisplaySide side,
+      @Nullable Project.NameKey project,
+      Change.Id changeId,
+      DiffObject diffObject,
+      String path) {
     initWidget(uiBinder.createAndBindUi(this));
     icon.setTitle(PatchUtil.C.addFileCommentToolTip());
     icon.addStyleName(Gerrit.RESOURCES.css().link());
@@ -79,6 +88,7 @@
     this.parent = parent;
     this.side = side;
     this.sideA = side == DisplaySide.A;
+    this.project = project;
     this.changeId = changeId;
     this.revision = diffObject.asPatchSetId();
     this.idActive = diffObject;
@@ -147,8 +157,7 @@
     }
   }
 
-  void setUpBlame(
-      final CodeMirror cm, final boolean isBase, final PatchSet.Id rev, final String path) {
+  void setUpBlame(final CodeMirror cm, boolean isBase, PatchSet.Id rev, String path) {
     if (!Patch.isMagic(path) && Gerrit.isSignedIn() && Gerrit.info().change().allowBlame()) {
       Anchor blameIcon = createBlameIcon();
       blameIcon.addClickHandler(
@@ -158,7 +167,7 @@
               if (cm.extras().getBlameInfo() != null) {
                 cm.extras().toggleAnnotation();
               } else {
-                ChangeApi.blame(rev, path, isBase)
+                ChangeApi.blame(Project.NameKey.asStringOrNull(project), rev, path, isBase)
                     .get(
                         new GerritCallback<JsArray<BlameInfo>>() {
 
@@ -180,7 +189,7 @@
     Anchor anchor =
         new Anchor(
             new ImageResourceRenderer().render(Gerrit.RESOURCES.edit()),
-            "#" + Dispatcher.toEditScreen(id, path));
+            "#" + Dispatcher.toEditScreen(project, id, path));
     anchor.setTitle(PatchUtil.C.edit());
     return anchor;
   }
@@ -207,8 +216,8 @@
     return new InlineHyperlink(
         label,
         parent.isSideBySide()
-            ? Dispatcher.toSideBySide(diffBase, revision.asPatchSetId(), path)
-            : Dispatcher.toUnified(diffBase, revision.asPatchSetId(), path));
+            ? Dispatcher.toSideBySide(project, diffBase, revision.asPatchSetId(), path)
+            : Dispatcher.toUnified(project, diffBase, revision.asPatchSetId(), path));
   }
 
   private Anchor createDownloadLink() {
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 4d781ea..ed4ac25 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
@@ -322,13 +322,10 @@
       prefs.tabSize(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
         view.operation(
-            new Runnable() {
-              @Override
-              public void run() {
-                int v = prefs.tabSize();
-                for (CodeMirror cm : view.getCms()) {
-                  cm.setOption("tabSize", v);
-                }
+            () -> {
+              int size = prefs.tabSize();
+              for (CodeMirror cm : view.getCms()) {
+                cm.setOption("tabSize", size);
               }
             });
       }
@@ -341,13 +338,7 @@
     if (v != null && v.length() > 0) {
       prefs.lineLength(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
-        view.operation(
-            new Runnable() {
-              @Override
-              public void run() {
-                view.setLineLength(prefs.lineLength());
-              }
-            });
+        view.operation(() -> view.setLineLength(prefs.lineLength()));
       }
     }
   }
@@ -448,7 +439,7 @@
 
   @UiHandler("mode")
   void onMode(@SuppressWarnings("unused") ChangeEvent e) {
-    final String mode = getSelectedMode();
+    String mode = getSelectedMode();
     prefs.syntaxHighlighting(true);
     syntaxHighlighting.setValue(true, false);
     new ModeInjector()
@@ -461,12 +452,9 @@
                     && Objects.equals(mode, getSelectedMode())
                     && view.isAttached()) {
                   view.operation(
-                      new Runnable() {
-                        @Override
-                        public void run() {
-                          view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
-                          view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
-                        }
+                      () -> {
+                        view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
+                        view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
                       });
                 }
               }
@@ -483,13 +471,10 @@
     prefs.showWhitespaceErrors(e.getValue());
     if (view != null) {
       view.operation(
-          new Runnable() {
-            @Override
-            public void run() {
-              boolean s = prefs.showWhitespaceErrors();
-              for (CodeMirror cm : view.getCms()) {
-                cm.setOption("showTrailingSpace", s);
-              }
+          () -> {
+            boolean s = prefs.showWhitespaceErrors();
+            for (CodeMirror cm : view.getCms()) {
+              cm.setOption("showTrailingSpace", s);
             }
           });
     }
@@ -537,7 +522,7 @@
 
   @UiHandler("theme")
   void onTheme(@SuppressWarnings("unused") ChangeEvent e) {
-    final Theme newTheme = getSelectedTheme();
+    Theme newTheme = getSelectedTheme();
     prefs.theme(newTheme);
     if (view != null) {
       ThemeLoader.loadTheme(
@@ -546,15 +531,12 @@
             @Override
             public void onSuccess(Void result) {
               view.operation(
-                  new Runnable() {
-                    @Override
-                    public void run() {
-                      if (getSelectedTheme() == newTheme && isAttached()) {
-                        String t = newTheme.name().toLowerCase();
-                        view.getCmFromSide(DisplaySide.A).setOption("theme", t);
-                        view.getCmFromSide(DisplaySide.B).setOption("theme", t);
-                        view.setThemeStyles(newTheme.isDark());
-                      }
+                  () -> {
+                    if (getSelectedTheme() == newTheme && isAttached()) {
+                      String t = newTheme.name().toLowerCase();
+                      view.getCmFromSide(DisplaySide.A).setOption("theme", t);
+                      view.getCmFromSide(DisplaySide.B).setOption("theme", t);
+                      view.setThemeStyles(newTheme.isDark());
                     }
                   });
             }
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 ce698027..1ddf895 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
@@ -26,7 +26,9 @@
 import com.google.gerrit.client.patches.PatchUtil;
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -52,6 +54,7 @@
   }
 
   private final PatchSet.Id psId;
+  @Nullable private final Project.NameKey project;
   private final CommentInfo comment;
   private final DisplaySide displaySide;
   private DraftBox replyBox;
@@ -73,6 +76,7 @@
   PublishedBox(
       CommentGroup group,
       CommentLinkProcessor clp,
+      @Nullable Project.NameKey project,
       PatchSet.Id psId,
       CommentInfo info,
       DisplaySide displaySide,
@@ -80,6 +84,7 @@
     super(group, info.range());
 
     this.psId = psId;
+    this.project = project;
     this.comment = info;
     this.displaySide = displaySide;
 
@@ -194,6 +199,7 @@
       CommentInfo input = CommentInfo.createReply(comment);
       input.message(PatchUtil.C.cannedReplyDone());
       CommentApi.createDraft(
+          Project.NameKey.asStringOrNull(project),
           psId,
           input,
           new GerritCallback<CommentInfo>() {
@@ -213,7 +219,7 @@
   @UiHandler("fix")
   void onFix(ClickEvent e) {
     e.stopPropagation();
-    String t = Dispatcher.toEditScreen(psId, comment.path(), comment.line());
+    String t = Dispatcher.toEditScreen(project, psId, comment.path(), comment.line());
     if (!Gerrit.isSignedIn()) {
       Gerrit.doSignIn(t);
     } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
index 6cb9b6a..ecdac46 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
@@ -64,12 +64,9 @@
     refresh =
         cmB.on(
             "refresh",
-            new Runnable() {
-              @Override
-              public void run() {
-                if (updateScale()) {
-                  updatePosition();
-                }
+            () -> {
+              if (updateScale()) {
+                updatePosition();
               }
             });
     updateScale();
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 1560597..d052323 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
@@ -24,8 +24,10 @@
 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.common.Nullable;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.Scheduler;
 import com.google.gwt.core.client.Scheduler.ScheduledCommand;
@@ -68,8 +70,13 @@
   private SideBySideCommentManager commentManager;
 
   public SideBySide(
-      DiffObject base, DiffObject revision, String path, DisplaySide startSide, int startLine) {
-    super(base, revision, path, startSide, startLine, DiffView.SIDE_BY_SIDE);
+      @Nullable Project.NameKey project,
+      DiffObject base,
+      DiffObject revision,
+      String path,
+      DisplaySide startSide,
+      int startLine) {
+    super(project, base, revision, path, startSide, startLine, DiffView.SIDE_BY_SIDE);
 
     diffTable = new SideBySideTable(this, base, revision, path);
     add(uiBinder.createAndBindUi(this));
@@ -85,6 +92,7 @@
         commentManager =
             new SideBySideCommentManager(
                 SideBySide.this,
+                getProject(),
                 base,
                 revision,
                 path,
@@ -102,14 +110,11 @@
     super.onShowView();
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            resizeCodeMirror();
-            chunkManager.adjustPadding();
-            cmA.refresh();
-            cmB.refresh();
-          }
+        () -> {
+          resizeCodeMirror();
+          chunkManager.adjustPadding();
+          cmA.refresh();
+          cmB.refresh();
         });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
@@ -141,7 +146,7 @@
   }
 
   @Override
-  void registerCmEvents(final CodeMirror cm) {
+  void registerCmEvents(CodeMirror cm) {
     super.registerCmEvents(cm);
 
     KeyMap keyMap =
@@ -183,8 +188,8 @@
     };
   }
 
-  private void display(final CommentsCollections comments) {
-    final DiffInfo diff = getDiff();
+  private void display(CommentsCollections comments) {
+    DiffInfo diff = getDiff();
     setThemeStyles(prefs.theme().isDark());
     setShowIntraline(prefs.intralineDifference());
     if (prefs.showLineNumbers()) {
@@ -209,18 +214,15 @@
     chunkManager = new SideBySideChunkManager(this, cmA, cmB, diffTable.scrollbar);
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            // Estimate initial CodeMirror height, fixed up in onShowView.
-            int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
-            cmA.setHeight(height);
-            cmB.setHeight(height);
+        () -> {
+          // Estimate initial CodeMirror height, fixed up in onShowView.
+          int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
+          cmA.setHeight(height);
+          cmB.setHeight(height);
 
-            render(diff);
-            commentManager.render(comments, prefs.expandAllComments());
-            skipManager.render(prefs.context(), diff);
-          }
+          render(diff);
+          commentManager.render(comments, prefs.expandAllComments());
+          skipManager.render(prefs.context(), diff);
         });
 
     registerCmEvents(cmA);
@@ -237,7 +239,8 @@
   private List<InlineHyperlink> getUnifiedDiffLink() {
     InlineHyperlink toUnifiedDiffLink = new InlineHyperlink();
     toUnifiedDiffLink.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.unifiedDiff()));
-    toUnifiedDiffLink.setTargetHistoryToken(Dispatcher.toUnified(base, revision, path));
+    toUnifiedDiffLink.setTargetHistoryToken(
+        Dispatcher.toUnified(getProject(), base, revision, path));
     toUnifiedDiffLink.setTitle(PatchUtil.C.unifiedDiff());
     return Collections.singletonList(toUnifiedDiffLink);
   }
@@ -319,66 +322,52 @@
   }
 
   @Override
-  Runnable updateActiveLine(final CodeMirror cm) {
-    final CodeMirror other = otherCm(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() {
-                    operation(
-                        new Runnable() {
-                          @Override
-                          public void run() {
-                            LineHandle handle =
-                                cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                            if (!cm.extras().activeLine(handle)) {
-                              return;
-                            }
+  Runnable updateActiveLine(CodeMirror cm) {
+    CodeMirror other = otherCm(cm);
+    return () -> {
+      // 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() {
+                  operation(
+                      () -> {
+                        LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                        if (!cm.extras().activeLine(handle)) {
+                          return;
+                        }
 
-                            LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
-                            if (info.isAligned()) {
-                              other.extras().activeLine(other.getLineHandle(info.getLine()));
-                            } else {
-                              other.extras().clearActiveLine();
-                            }
-                          }
-                        });
-                  }
-                });
-      }
+                        LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
+                        if (info.isAligned()) {
+                          other.extras().activeLine(other.getLineHandle(info.getLine()));
+                        } else {
+                          other.extras().clearActiveLine();
+                        }
+                      });
+                }
+              });
     };
   }
 
-  private Runnable moveCursorToSide(final CodeMirror cmSrc, DisplaySide sideDst) {
-    final CodeMirror cmDst = getCmFromSide(sideDst);
+  private Runnable moveCursorToSide(CodeMirror cmSrc, DisplaySide sideDst) {
+    CodeMirror cmDst = getCmFromSide(sideDst);
     if (cmDst == cmSrc) {
-      return new Runnable() {
-        @Override
-        public void run() {}
-      };
+      return () -> {};
     }
 
-    final DisplaySide sideSrc = cmSrc.side();
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cmSrc.extras().hasActiveLine()) {
-          cmDst.setCursor(
-              Pos.create(
-                  lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine()))
-                      .getLine()));
-        }
-        cmDst.focus();
+    DisplaySide sideSrc = cmSrc.side();
+    return () -> {
+      if (cmSrc.extras().hasActiveLine()) {
+        cmDst.setCursor(
+            Pos.create(
+                lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine())).getLine()));
       }
+      cmDst.focus();
     };
   }
 
@@ -389,20 +378,8 @@
   }
 
   @Override
-  void operation(final Runnable apply) {
-    cmA.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmB.operation(
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    apply.run();
-                  }
-                });
-          }
-        });
+  void operation(Runnable apply) {
+    cmA.operation(() -> cmB.operation(apply::run));
   }
 
   @Override
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
index a78e59e..2877794 100644
--- 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
@@ -219,7 +219,7 @@
    * @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) {
+  private void addPadding(CodeMirror cm, int line, int len) {
     if (0 < len) {
       Element pad = DOM.createDiv();
       pad.setClassName(SideBySideTable.style.padding());
@@ -245,16 +245,13 @@
   }
 
   @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);
-      }
+  Runnable diffChunkNav(CodeMirror cm, Direction dir) {
+    return () -> {
+      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);
     };
   }
 
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
index 6fcd6c8..c728f6f 100644
--- 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
@@ -88,29 +88,26 @@
   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);
+            () -> {
+              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);
               }
             });
   }
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
index 7503711..09c5b07 100644
--- 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
@@ -18,7 +18,9 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.changes.CommentInfo;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import java.util.Collection;
 import java.util.Map;
 import java.util.SortedMap;
@@ -29,12 +31,13 @@
 class SideBySideCommentManager extends CommentManager {
   SideBySideCommentManager(
       SideBySide host,
+      @Nullable Project.NameKey project,
       DiffObject base,
       PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
       boolean open) {
-    super(host, base, revision, path, clp, open);
+    super(host, project, base, revision, path, clp, open);
   }
 
   @Override
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
index 7465c81..c65dcf0 100644
--- 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
@@ -75,12 +75,7 @@
   }
 
   Runnable toggleA() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        setVisibleA(!isVisibleA());
-      }
-    };
+    return () -> setVisibleA(!isVisibleA());
   }
 
   void setVisibleB(boolean show) {
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 03cfd60..c138f37 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
@@ -62,7 +62,7 @@
   private TextMarker textMarker;
   private SkipBar otherBar;
 
-  SkipBar(SkipManager manager, final CodeMirror cm) {
+  SkipBar(SkipManager manager, CodeMirror cm) {
     this.manager = manager;
     this.cm = cm;
 
@@ -91,12 +91,9 @@
       }
       if (isNew) {
         lineWidget.onFirstRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                int w = cm.getGutterElement().getOffsetWidth();
-                getElement().getStyle().setPaddingLeft(w, Unit.PX);
-              }
+            () -> {
+              int w = cm.getGutterElement().getOffsetWidth();
+              getElement().getStyle().setPaddingLeft(w, Unit.PX);
             });
       }
     }
@@ -110,14 +107,7 @@
                 .set("inclusiveLeft", true)
                 .set("inclusiveRight", true));
 
-    textMarker.on(
-        "beforeCursorEnter",
-        new Runnable() {
-          @Override
-          public void run() {
-            expandAll();
-          }
-        });
+    textMarker.on("beforeCursorEnter", this::expandAll);
 
     int skipped = end - start + 1;
     if (skipped <= UP_DOWN_THRESHOLD) {
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
index 0f0ba41..7bd9804 100644
--- 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
@@ -24,13 +24,14 @@
 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.common.Nullable;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.Project;
 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;
@@ -68,8 +69,13 @@
   private boolean autoHideDiffTableHeader;
 
   public Unified(
-      DiffObject base, DiffObject revision, String path, DisplaySide startSide, int startLine) {
-    super(base, revision, path, startSide, startLine, DiffView.UNIFIED_DIFF);
+      @Nullable Project.NameKey project,
+      DiffObject base,
+      DiffObject revision,
+      String path,
+      DisplaySide startSide,
+      int startLine) {
+    super(project, base, revision, path, startSide, startLine, DiffView.UNIFIED_DIFF);
 
     diffTable = new UnifiedTable(this, base, revision, path);
     add(uiBinder.createAndBindUi(this));
@@ -85,6 +91,7 @@
         commentManager =
             new UnifiedCommentManager(
                 Unified.this,
+                getProject(),
                 base,
                 revision,
                 path,
@@ -102,12 +109,9 @@
     super.onShowView();
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            resizeCodeMirror();
-            cm.refresh();
-          }
+        () -> {
+          resizeCodeMirror();
+          cm.refresh();
         });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
@@ -137,18 +141,15 @@
   }
 
   @Override
-  void registerCmEvents(final CodeMirror cm) {
+  void registerCmEvents(CodeMirror cm) {
     super.registerCmEvents(cm);
 
     cm.on(
         "scroll",
-        new Runnable() {
-          @Override
-          public void run() {
-            ScrollInfo si = cm.getScrollInfo();
-            if (autoHideDiffTableHeader) {
-              updateDiffTableHeader(si);
-            }
+        () -> {
+          ScrollInfo si = cm.getScrollInfo();
+          if (autoHideDiffTableHeader) {
+            updateDiffTableHeader(si);
           }
         });
     maybeRegisterRenderEntireFileKeyMap(cm);
@@ -171,8 +172,8 @@
     };
   }
 
-  private void display(final CommentsCollections comments) {
-    final DiffInfo diff = getDiff();
+  private void display(CommentsCollections comments) {
+    DiffInfo diff = getDiff();
     setThemeStyles(prefs.theme().isDark());
     setShowIntraline(prefs.intralineDifference());
     if (prefs.showLineNumbers()) {
@@ -186,17 +187,14 @@
     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);
+        () -> {
+          // 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);
-          }
+          render(diff);
+          commentManager.render(comments, prefs.expandAllComments());
+          skipManager.render(prefs.context(), diff);
         });
 
     registerCmEvents(cm);
@@ -212,7 +210,8 @@
     InlineHyperlink toSideBySideDiffLink = new InlineHyperlink();
     toSideBySideDiffLink.setHTML(
         new ImageResourceRenderer().render(Gerrit.RESOURCES.sideBySideDiff()));
-    toSideBySideDiffLink.setTargetHistoryToken(Dispatcher.toSideBySide(base, revision, path));
+    toSideBySideDiffLink.setTargetHistoryToken(
+        Dispatcher.toSideBySide(getProject(), base, revision, path));
     toSideBySideDiffLink.setTitle(PatchUtil.C.sideBySideDiff());
     return Collections.singletonList(toSideBySideDiffLink);
   }
@@ -317,25 +316,19 @@
   }
 
   @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);
-                  }
-                });
-      }
+  Runnable updateActiveLine(CodeMirror cm) {
+    return () -> {
+      // 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(
+              () -> {
+                LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                cm.extras().activeLine(handle);
+              });
     };
   }
 
@@ -354,14 +347,8 @@
   }
 
   @Override
-  void operation(final Runnable apply) {
-    cm.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            apply.run();
-          }
-        });
+  void operation(Runnable apply) {
+    cm.operation(apply::run);
   }
 
   @Override
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
index 3939f99..1a662e2 100644
--- 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
@@ -213,18 +213,15 @@
   }
 
   @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);
-      }
+  Runnable diffChunkNav(CodeMirror cm, Direction dir) {
+    return () -> {
+      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 --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
index a6912df..6d5fba3 100644
--- 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
@@ -50,29 +50,26 @@
   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);
+            () -> {
+              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);
               }
             });
   }
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
index 1d9b55a..c92075f 100644
--- 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
@@ -21,7 +21,9 @@
 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.common.Nullable;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
@@ -43,12 +45,13 @@
 
   UnifiedCommentManager(
       Unified host,
+      @Nullable Project.NameKey project,
       DiffObject base,
       PatchSet.Id revision,
       String path,
       CommentLinkProcessor clp,
       boolean open) {
-    super(host, base, revision, path, clp, open);
+    super(host, project, base, revision, path, clp, open);
     mergedMap = new TreeMap<>();
     duplicates = new HashMap<>();
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java
index ea2f2cf..50ef0d7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UpToChangeCommand.java
@@ -16,21 +16,25 @@
 
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.patches.PatchUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.event.dom.client.KeyPressEvent;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
 
 class UpToChangeCommand extends KeyCommand {
   private final PatchSet.Id revision;
+  @Nullable private final Project.NameKey project;
 
-  UpToChangeCommand(PatchSet.Id revision, int mask, int key) {
+  UpToChangeCommand(@Nullable Project.NameKey project, PatchSet.Id revision, int mask, int key) {
     super(mask, key, PatchUtil.C.upToChange());
     this.revision = revision;
+    this.project = project;
   }
 
   @Override
-  public void onKeyPress(final KeyPressEvent event) {
-    Gerrit.display(PageLinks.toChange(revision.getParentKey(), revision.getId()));
+  public void onKeyPress(KeyPressEvent event) {
+    Gerrit.display(PageLinks.toChange(project, revision.getParentKey(), revision.getId()));
   }
 }
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 027fb40..cbf12a3 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
@@ -41,15 +41,16 @@
 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.Nullable;
 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.gerrit.reviewdb.client.Project;
 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.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -100,6 +101,7 @@
     String hideBase();
   }
 
+  @Nullable private Project.NameKey projectKey;
   private final PatchSet.Id revision;
   private final String path;
   private final int startLine;
@@ -130,7 +132,8 @@
   private HandlerRegistration closeHandler;
   private int generation;
 
-  public EditScreen(Patch.Key patch, int startLine) {
+  public EditScreen(@Nullable Project.NameKey projectKey, Patch.Key patch, int startLine) {
+    this.projectKey = projectKey;
     this.revision = patch.getParentKey();
     this.path = patch.get();
     this.startLine = startLine - 1;
@@ -188,11 +191,13 @@
             }));
 
     ChangeApi.detail(
+        Project.NameKey.asStringOrNull(projectKey),
         revision.getParentKey().get(),
         group1.add(
             new AsyncCallback<ChangeInfo>() {
               @Override
               public void onSuccess(ChangeInfo c) {
+                projectKey = c.projectNameKey();
                 project.setInnerText(c.project());
                 SafeHtml.setInnerHTML(filePath, Header.formatPath(path));
               }
@@ -203,6 +208,7 @@
 
     if (revision.get() == 0) {
       ChangeEditApi.getMeta(
+          Project.NameKey.asStringOrNull(projectKey),
           revision,
           path,
           group1.add(
@@ -218,6 +224,7 @@
 
       if (prefs.showBase()) {
         ChangeEditApi.get(
+            projectKey,
             revision,
             path,
             true /* base */,
@@ -238,7 +245,7 @@
     } 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)
+      DiffApi.diff(Project.NameKey.asStringOrNull(projectKey), revision, path)
           .webLinksOnly()
           .get(
               group1.addFinal(
@@ -254,6 +261,7 @@
     }
 
     ChangeEditApi.get(
+        projectKey,
         revision,
         path,
         group2.add(
@@ -318,12 +326,7 @@
   }
 
   private Runnable gotoLine() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        cmEdit.execCommand("jumpToLine");
-      }
-    };
+    return () -> cmEdit.execCommand("jumpToLine");
   }
 
   @Override
@@ -433,6 +436,7 @@
     if (shouldShow) {
       if (baseContent == null) {
         ChangeEditApi.get(
+            projectKey,
             revision,
             path,
             true /* base */,
@@ -472,21 +476,9 @@
     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 setTheme(Theme newTheme) {
+    cmBase.operation(() -> cmBase.setOption("theme", newTheme.name().toLowerCase()));
+    cmEdit.operation(() -> cmEdit.setOption("theme", newTheme.name().toLowerCase()));
   }
 
   void setLineLength(int length) {
@@ -504,21 +496,9 @@
     cmEdit.setOption("lineNumbers", show);
   }
 
-  void setShowWhitespaceErrors(final boolean show) {
-    cmBase.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmBase.setOption("showTrailingSpace", show);
-          }
-        });
-    cmEdit.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmEdit.setOption("showTrailingSpace", show);
-          }
-        });
+  void setShowWhitespaceErrors(boolean show) {
+    cmBase.operation(() -> cmBase.setOption("showTrailingSpace", show));
+    cmEdit.operation(() -> cmEdit.setOption("showTrailingSpace", show));
   }
 
   void setShowTabs(boolean show) {
@@ -559,7 +539,7 @@
   }
 
   private void upToChange() {
-    Gerrit.display(PageLinks.toChangeInEditMode(revision.getParentKey()));
+    Gerrit.display(PageLinks.toChangeInEditMode(projectKey, revision.getParentKey()));
   }
 
   private void initEditor() {
@@ -636,42 +616,26 @@
     InlineHyperlink sbs = new InlineHyperlink();
     sbs.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.sideBySideDiff()));
     sbs.setTargetHistoryToken(
-        Dispatcher.toPatch("sidebyside", null, new Patch.Key(revision, path)));
+        Dispatcher.toPatch(projectKey, "sidebyside", null, new Patch.Key(revision, path)));
     sbs.setTitle(PatchUtil.C.sideBySideDiff());
     linkPanel.add(sbs);
 
     InlineHyperlink unified = new InlineHyperlink();
     unified.setHTML(new ImageResourceRenderer().render(Gerrit.RESOURCES.unifiedDiff()));
     unified.setTargetHistoryToken(
-        Dispatcher.toPatch("unified", null, new Patch.Key(revision, path)));
+        Dispatcher.toPatch(projectKey, "unified", null, new Patch.Key(revision, path)));
     unified.setTitle(PatchUtil.C.unifiedDiff());
     linkPanel.add(unified);
   }
 
   private Runnable updateCursorPosition() {
-    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() {
-                    cmEdit.operation(
-                        new Runnable() {
-                          @Override
-                          public void run() {
-                            updateActiveLine();
-                          }
-                        });
-                  }
-                });
-      }
+    return () -> {
+      // 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(() -> cmEdit.operation(this::updateActiveLine));
     };
   }
 
@@ -689,37 +653,35 @@
   }
 
   private Runnable save() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (!cmEdit.isClean(generation)) {
-          close.setEnabled(false);
-          String text = cmEdit.getValue();
-          if (Patch.COMMIT_MSG.equals(path)) {
-            String trimmed = text.trim() + "\r";
-            if (!trimmed.equals(text)) {
-              text = trimmed;
-              cmEdit.setValue(text);
-            }
+    return () -> {
+      if (!cmEdit.isClean(generation)) {
+        close.setEnabled(false);
+        String text = cmEdit.getValue();
+        if (Patch.COMMIT_MSG.equals(path)) {
+          String trimmed = text.trim() + "\r";
+          if (!trimmed.equals(text)) {
+            text = trimmed;
+            cmEdit.setValue(text);
           }
-          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(cmEdit.isClean(g));
-                }
-
-                @Override
-                public void onFailure(final Throwable caught) {
-                  close.setEnabled(true);
-                }
-              });
         }
+        final int g = cmEdit.changeGeneration(false);
+        ChangeEditApi.put(
+            Project.NameKey.asStringOrNull(projectKey),
+            revision.getParentKey().get(),
+            path,
+            text,
+            new GerritCallback<VoidResult>() {
+              @Override
+              public void onSuccess(VoidResult result) {
+                generation = g;
+                setClean(cmEdit.isClean(g));
+              }
+
+              @Override
+              public void onFailure(Throwable caught) {
+                close.setEnabled(true);
+              }
+            });
       }
     };
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
index 74cfaf1..01c4d26 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/groups/GroupApi.java
@@ -44,7 +44,7 @@
   }
 
   /** Check if the current user is owner of a group */
-  public static void isGroupOwner(String groupName, final AsyncCallback<Boolean> cb) {
+  public static void isGroupOwner(String groupName, AsyncCallback<Boolean> cb) {
     GroupMap.myOwned(
         groupName,
         new AsyncCallback<GroupMap>() {
@@ -105,7 +105,7 @@
 
   /** Add members to a group. */
   public static void addMembers(
-      AccountGroup.UUID group, Set<String> members, final AsyncCallback<JsArray<AccountInfo>> cb) {
+      AccountGroup.UUID group, Set<String> members, AsyncCallback<JsArray<AccountInfo>> cb) {
     if (members.size() == 1) {
       addMember(
           group,
@@ -132,7 +132,7 @@
 
   /** Remove members from a group. */
   public static void removeMembers(
-      AccountGroup.UUID group, Set<Integer> ids, final AsyncCallback<VoidResult> cb) {
+      AccountGroup.UUID group, Set<Integer> ids, AsyncCallback<VoidResult> cb) {
     if (ids.size() == 1) {
       members(group).id(ids.iterator().next().toString()).delete(cb);
     } else {
@@ -181,7 +181,7 @@
 
   /** Remove included groups from a group. */
   public static void removeIncludedGroups(
-      AccountGroup.UUID group, Set<AccountGroup.UUID> ids, final AsyncCallback<VoidResult> cb) {
+      AccountGroup.UUID group, Set<AccountGroup.UUID> ids, AsyncCallback<VoidResult> cb) {
     if (ids.size() == 1) {
       AccountGroup.UUID g = ids.iterator().next();
       groups(group).id(g.get()).delete(cb);
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 76147f5..73ac183 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
@@ -28,7 +28,11 @@
   public static void match(String match, int limit, int start, AsyncCallback<GroupMap> cb) {
     RestApi call = groups();
     if (match != null) {
-      call.addParameter("m", match);
+      if (match.startsWith("^")) {
+        call.addParameter("r", match);
+      } else {
+        call.addParameter("m", match);
+      }
     }
     if (limit > 0) {
       call.addParameter("n", limit);
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 c96d331..a6a7ce6 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
@@ -57,6 +57,18 @@
   public final native InheritedBooleanInfo rejectImplicitMerges()
       /*-{ return this.reject_implicit_merges; }-*/ ;
 
+  public final native InheritedBooleanInfo privateByDefault()
+      /*-{ return this.private_by_default; }-*/ ;
+
+  public final native InheritedBooleanInfo workInProgressByDefault()
+      /*-{ return this.work_in_progress_by_default; }-*/ ;
+
+  public final native InheritedBooleanInfo enableReviewerByEmail()
+      /*-{ return this.enable_reviewer_by_email; }-*/ ;
+
+  public final native InheritedBooleanInfo matchAuthorToCommitterDate()
+      /*-{ return this.match_author_to_committer_date; }-*/ ;
+
   public final SubmitType submitType() {
     return SubmitType.valueOf(submitTypeRaw());
   }
@@ -113,6 +125,9 @@
 
   final native ThemeInfo theme() /*-{ return this.theme; }-*/;
 
+  final native NativeMap<JsArrayString>
+      extensionPanelNames() /*-{ return this.extension_panel_names; }-*/;
+
   protected ConfigInfo() {}
 
   static class CommentLinkInfo extends JavaScriptObject {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
index e41cf120..7262b3a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
@@ -16,12 +16,14 @@
 
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.info.ChangeInfo;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 /** Cache of {@link ConfigInfo} objects by project name. */
@@ -48,6 +50,10 @@
     public ThemeInfo getTheme() {
       return info.theme();
     }
+
+    public List<String> getExtensionPanelNames(String extensionPoint) {
+      return Natives.asList(info.extensionPanelNames().get(extensionPoint));
+    }
   }
 
   public static void get(Project.NameKey name, AsyncCallback<Entry> cb) {
@@ -87,7 +93,7 @@
         };
   }
 
-  private void getImpl(final String name, final AsyncCallback<Entry> cb) {
+  private void getImpl(String name, AsyncCallback<Entry> cb) {
     Entry e = cache.get(name);
     if (e != null) {
       cb.onSuccess(e);
@@ -110,13 +116,14 @@
         });
   }
 
-  private void getImpl(final Integer id, final AsyncCallback<Entry> cb) {
+  private void getImpl(Integer id, AsyncCallback<Entry> cb) {
     String name = changeToProject.get(id);
     if (name != null) {
       getImpl(name, cb);
       return;
     }
-    ChangeApi.change(id)
+    // TODO(hiesel) Make a preflight request to get project before we deprecate the numeric changeId
+    ChangeApi.change(null, id)
         .get(
             new AsyncCallback<ChangeInfo>() {
               @Override
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 ff4c810..7a4ec83 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
@@ -152,6 +152,10 @@
       InheritableBoolean enableSignedPush,
       InheritableBoolean requireSignedPush,
       InheritableBoolean rejectImplicitMerges,
+      InheritableBoolean privateByDefault,
+      InheritableBoolean workInProgressByDefault,
+      InheritableBoolean enableReviewerByEmail,
+      InheritableBoolean matchAuthorToCommitterDate,
       String maxObjectSizeLimit,
       SubmitType submitType,
       ProjectState state,
@@ -171,15 +175,19 @@
       in.setRequireSignedPush(requireSignedPush);
     }
     in.setRejectImplicitMerges(rejectImplicitMerges);
+    in.setPrivateByDefault(privateByDefault);
+    in.setWorkInProgressByDefault(workInProgressByDefault);
     in.setMaxObjectSizeLimit(maxObjectSizeLimit);
     in.setSubmitType(submitType);
     in.setState(state);
     in.setPluginConfigValues(pluginConfigValues);
+    in.setEnableReviewerByEmail(enableReviewerByEmail);
+    in.setMatchAuthorToCommitterDate(matchAuthorToCommitterDate);
 
     project(name).view("config").put(in, cb);
   }
 
-  public static void getParent(Project.NameKey name, final AsyncCallback<Project.NameKey> cb) {
+  public static void getParent(Project.NameKey name, AsyncCallback<Project.NameKey> cb) {
     project(name)
         .view("parent")
         .get(
@@ -299,6 +307,33 @@
       setRequireSignedPushRaw(v.name());
     }
 
+    final void setPrivateByDefault(InheritableBoolean v) {
+      setPrivateByDefault(v.name());
+    }
+
+    private native void setPrivateByDefault(String v) /*-{ if(v)this.private_by_default=v; }-*/;
+
+    final void setWorkInProgressByDefault(InheritableBoolean v) {
+      setWorkInProgressByDefault(v.name());
+    }
+
+    private native void setWorkInProgressByDefault(
+        String v) /*-{ if(v)this.work_in_progress_by_default=v; }-*/;
+
+    final void setEnableReviewerByEmail(InheritableBoolean v) {
+      setEnableReviewerByEmailRaw(v.name());
+    }
+
+    final void setMatchAuthorToCommitterDate(InheritableBoolean v) {
+      setMatchAuthorToCommitterDateRaw(v.name());
+    }
+
+    private native void setMatchAuthorToCommitterDateRaw(String v)
+        /*-{ if(v)this.match_author_to_committer_date=v; }-*/ ;
+
+    private native void setEnableReviewerByEmailRaw(String v)
+        /*-{ if(v)this.enable_reviewer_by_email=v; }-*/ ;
+
     private native void setRequireSignedPushRaw(String v)
         /*-{ if(v)this.require_signed_push=v; }-*/ ;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
index 4327c07..5ff300d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
@@ -44,9 +44,9 @@
         .get(NativeMap.copyKeysIntoChildren(callback));
   }
 
-  public static void suggest(String prefix, int limit, AsyncCallback<ProjectMap> cb) {
+  public static void suggest(String match, int limit, AsyncCallback<ProjectMap> cb) {
     new RestApi("/projects/")
-        .addParameter("p", prefix)
+        .addParameter("m", match)
         .addParameter("n", limit)
         .addParameterRaw("type", "ALL")
         .addParameterTrue("d") // description
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 90a820f..af32d01 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
@@ -65,7 +65,7 @@
     return add(cb);
   }
 
-  public <T> Callback<T> add(final AsyncCallback<T> cb) {
+  public <T> Callback<T> add(AsyncCallback<T> cb) {
     checkFinalAdded();
     return handleAdd(cb);
   }
@@ -75,13 +75,13 @@
     return handleAdd(cb);
   }
 
-  public <T> Callback<T> addFinal(final AsyncCallback<T> cb) {
+  public <T> Callback<T> addFinal(AsyncCallback<T> cb) {
     checkFinalAdded();
     finalAdded = true;
     return handleAdd(cb);
   }
 
-  public <T> HttpCallback<T> addFinal(final HttpCallback<T> cb) {
+  public <T> HttpCallback<T> addFinal(HttpCallback<T> cb) {
     checkFinalAdded();
     finalAdded = true;
     return handleAdd(cb);
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 5688a31..2d6723a 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
@@ -32,7 +32,7 @@
     implements com.google.gwtjsonrpc.common.AsyncCallback<T>,
         com.google.gwt.user.client.rpc.AsyncCallback<T> {
   @Override
-  public void onFailure(final Throwable caught) {
+  public void onFailure(Throwable caught) {
     showFailure(caught);
   }
 
@@ -77,7 +77,7 @@
     return false;
   }
 
-  protected static boolean isInvalidXSRF(final Throwable caught) {
+  protected static boolean isInvalidXSRF(Throwable caught) {
     return caught instanceof InvocationException
         && caught.getMessage().equals(JsonConstants.ERROR_INVALID_XSRF);
   }
@@ -94,17 +94,17 @@
             && caught.getMessage().equals(NoSuchEntityException.MESSAGE));
   }
 
-  protected static boolean isNoSuchAccount(final Throwable caught) {
+  protected static boolean isNoSuchAccount(Throwable caught) {
     return caught instanceof RemoteJsonException
         && caught.getMessage().startsWith(NoSuchAccountException.MESSAGE);
   }
 
-  protected static boolean isNameAlreadyUsed(final Throwable caught) {
+  protected static boolean isNameAlreadyUsed(Throwable caught) {
     return caught instanceof RemoteJsonException
         && caught.getMessage().startsWith(NameAlreadyUsedException.MESSAGE);
   }
 
-  protected static boolean isNoSuchGroup(final Throwable caught) {
+  protected static boolean isNoSuchGroup(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 250bc6e..e2a9ffb 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
@@ -116,7 +116,7 @@
     }
 
     @Override
-    public void onResponseReceived(Request req, final Response res) {
+    public void onResponseReceived(Request req, Response res) {
       int status = res.getStatusCode();
       if (status == Response.SC_NO_CONTENT) {
         cb.onSuccess(new HttpResponse<T>(res, null, null));
@@ -179,7 +179,7 @@
               }
             };
 
-        // Defer handling the response if the parse took a while.
+        // Defer handling the response if the create took a while.
         if ((System.currentTimeMillis() - start) > 75) {
           Scheduler.get().scheduleDeferred(cmd);
         } else {
@@ -258,6 +258,10 @@
     return idRaw(URL.encodePathSegment(id));
   }
 
+  public RestApi id(String project, int id) {
+    return idRaw(URL.encodePathSegment(project) + "~" + id);
+  }
+
   public RestApi id(int id) {
     return idRaw(Integer.toString(id));
   }
@@ -499,7 +503,7 @@
     }
   }
 
-  private static <T extends JavaScriptObject> HttpCallback<T> wrap(final AsyncCallback<T> cb) {
+  private static <T extends JavaScriptObject> HttpCallback<T> wrap(AsyncCallback<T> cb) {
     return new HttpCallback<T>() {
       @Override
       public void onSuccess(HttpResponse<T> r) {
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 74b45df..3aae04a 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
@@ -24,12 +24,12 @@
 public abstract class ScreenLoadCallback<T> extends GerritCallback<T> {
   private final Screen screen;
 
-  public ScreenLoadCallback(final Screen s) {
+  public ScreenLoadCallback(Screen s) {
     screen = s;
   }
 
   @Override
-  public final void onSuccess(final T result) {
+  public final void onSuccess(T result) {
     if (screen.isAttached()) {
       preDisplay(result);
       screen.display();
@@ -42,7 +42,7 @@
   protected void postDisplay() {}
 
   @Override
-  public void onFailure(final Throwable caught) {
+  public void onFailure(Throwable caught) {
     if (isSigninFailure(caught)) {
       new NotSignedInDialog().center();
     } else if (isNoSuchEntity(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 80b8c66..bdebd68 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
@@ -32,7 +32,7 @@
   private Project.NameKey projectName;
 
   @Override
-  public void _onRequestSuggestions(final Request req, final Callback callback) {
+  public void _onRequestSuggestions(Request req, Callback callback) {
     GroupMap.suggestAccountGroupForProject(
         projectName == null ? null : projectName.get(),
         req.getQuery(),
@@ -58,7 +58,7 @@
   private static class AccountGroupSuggestion implements SuggestOracle.Suggestion {
     private final GroupInfo info;
 
-    AccountGroupSuggestion(final GroupInfo k) {
+    AccountGroupSuggestion(GroupInfo k) {
       info = k;
     }
 
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 78ae156..5038ad9 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
@@ -27,7 +27,7 @@
 /** Suggestion Oracle for Account entities. */
 public class AccountSuggestOracle extends SuggestAfterTypingNCharsOracle {
   @Override
-  public void _onRequestSuggestions(final Request req, final Callback cb) {
+  public void _onRequestSuggestions(Request req, Callback cb) {
     AccountApi.suggest(
         req.getQuery(),
         req.getLimit(),
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
index 5d8d56c..a1d2229 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AddMemberBox.java
@@ -29,8 +29,7 @@
   private final Button addMember;
   private final RemoteSuggestBox suggestBox;
 
-  public AddMemberBox(
-      final String buttonLabel, final String hint, final SuggestOracle suggestOracle) {
+  public AddMemberBox(final String buttonLabel, String hint, SuggestOracle suggestOracle) {
     addPanel = new FlowPanel();
     addMember = new Button(buttonLabel);
 
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 1ae4489..b54d752 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
@@ -17,17 +17,18 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 
 public class ChangeLink extends InlineHyperlink {
-  public static String permalink(final Change.Id c) {
+  public static String permalink(Change.Id c) {
     return GWT.getHostPageBaseURL() + c.get();
   }
 
   protected Change.Id cid;
 
-  public ChangeLink(final String text, final Change.Id c) {
-    super(text, PageLinks.toChange(c));
+  public ChangeLink(Project.NameKey project, Change.Id c, String text) {
+    super(text, PageLinks.toChange(project, c));
     getElement().setPropertyString("href", permalink(c));
     cid = c;
   }
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 85552c9..0a0c14a 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
@@ -51,7 +51,7 @@
               @Override
               protected void onRequestSuggestions(Request request, Callback done) {
                 List<BranchSuggestion> suggestions = new ArrayList<>();
-                for (final BranchInfo b : branches) {
+                for (BranchInfo b : branches) {
                   if (b.ref().contains(request.getQuery())) {
                     suggestions.add(new BranchSuggestion(b));
                   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommandMenuItem.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommandMenuItem.java
index 72bf06c..c5ee34f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommandMenuItem.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/CommandMenuItem.java
@@ -24,7 +24,7 @@
 public class CommandMenuItem extends Anchor implements ClickHandler {
   private final Command command;
 
-  public CommandMenuItem(final String text, final Command cmd) {
+  public CommandMenuItem(String text, Command cmd) {
     super(text);
     setStyleName(Gerrit.RESOURCES.css().menuItem());
     Roles.getMenuitemRole().set(getElement());
@@ -33,7 +33,7 @@
   }
 
   @Override
-  public void onClick(final ClickEvent event) {
+  public void onClick(ClickEvent event) {
     setFocus(false);
     command.execute();
   }
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 d497740..b68f329 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
@@ -37,7 +37,7 @@
 
   protected boolean sent;
 
-  public CommentedActionDialog(final String title, final String heading) {
+  public CommentedActionDialog(String title, String heading) {
     super(/* auto hide */ false, /* modal */ true);
     setGlassEnabled(true);
     setText(title);
@@ -48,7 +48,7 @@
     sendButton.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             enableButtons(false);
             onSend();
           }
@@ -59,7 +59,7 @@
     cancelButton.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             hide();
           }
         });
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java
index f65fb1b..c0b662a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ComplexDisclosurePanel.java
@@ -32,7 +32,7 @@
   private final DisclosurePanel main;
   private final Panel header;
 
-  public ComplexDisclosurePanel(final String text, final boolean isOpen) {
+  public ComplexDisclosurePanel(String text, boolean isOpen) {
     // Ick. GWT's DisclosurePanel won't let us subclass it, or do any
     // other modification of its header. We're stuck with injecting
     // into the DOM directly.
@@ -81,7 +81,7 @@
     return header;
   }
 
-  public void setContent(final Widget w) {
+  public void setContent(Widget w) {
     main.setContent(w);
   }
 
@@ -90,12 +90,12 @@
   }
 
   @Override
-  public HandlerRegistration addOpenHandler(final OpenHandler<DisclosurePanel> h) {
+  public HandlerRegistration addOpenHandler(OpenHandler<DisclosurePanel> h) {
     return main.addOpenHandler(h);
   }
 
   @Override
-  public HandlerRegistration addCloseHandler(final CloseHandler<DisclosurePanel> h) {
+  public HandlerRegistration addCloseHandler(CloseHandler<DisclosurePanel> h) {
     return main.addCloseHandler(h);
   }
 
@@ -109,7 +109,7 @@
    *
    * @param isOpen {@code true} to open, {@code false} to close
    */
-  public void setOpen(final boolean isOpen) {
+  public void setOpen(boolean isOpen) {
     main.setOpen(isOpen);
   }
 }
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 a9a17210..045e0ae 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
@@ -48,11 +48,11 @@
     return new MyFlexTable();
   }
 
-  protected RowItem getRowItem(final int row) {
+  protected RowItem getRowItem(int row) {
     return FancyFlexTable.<RowItem>getRowItem(table.getCellFormatter().getElement(row, 0));
   }
 
-  protected void setRowItem(final int row, final RowItem item) {
+  protected void setRowItem(int row, RowItem item) {
     setRowItem(table.getCellFormatter().getElement(row, 0), item);
   }
 
@@ -117,15 +117,15 @@
     return left;
   }
 
-  protected void resetHtml(final SafeHtml body) {
-    for (final Iterator<Widget> i = table.iterator(); i.hasNext(); ) {
+  protected void resetHtml(SafeHtml body) {
+    for (Iterator<Widget> i = table.iterator(); i.hasNext(); ) {
       i.next();
       i.remove();
     }
     impl.resetHtml(table, body);
   }
 
-  protected void scrollIntoView(final int topRow, final int endRow) {
+  protected void scrollIntoView(int topRow, int endRow) {
     final CellFormatter fmt = table.getCellFormatter();
     final Element top = fmt.getElement(topRow, C_ARROW).getParentElement();
     final Element end = fmt.getElement(endRow, C_ARROW).getParentElement();
@@ -164,7 +164,7 @@
     Document.get().setScrollTop(nTop);
   }
 
-  protected void applyDataRowStyle(final int newRow) {
+  protected void applyDataRowStyle(int newRow) {
     table.getCellFormatter().addStyleName(newRow, C_ARROW, Gerrit.RESOURCES.css().iconCell());
     table.getCellFormatter().addStyleName(newRow, C_ARROW, Gerrit.RESOURCES.css().leftMostCell());
   }
@@ -176,7 +176,7 @@
    * @return the td containing element {@code target}; null if {@code target} is not a member of
    *     this table.
    */
-  protected Element getParentCell(final Element target) {
+  protected Element getParentCell(Element target) {
     final Element body = FancyFlexTableImpl.getBodyElement(table);
     for (Element td = target; td != null && td != body; td = DOM.getParent(td)) {
       // If it's a TD, it might be the one we're looking for.
@@ -192,7 +192,7 @@
   }
 
   /** @return the row of the child element; -1 if the child is not in the table. */
-  protected int rowOf(final Element target) {
+  protected int rowOf(Element target) {
     final Element td = getParentCell(target);
     if (td == null) {
       return -1;
@@ -203,7 +203,7 @@
   }
 
   /** @return the cell of the child element; -1 if the child is not in the table. */
-  protected int columnOf(final Element target) {
+  protected int columnOf(Element target) {
     final Element td = getParentCell(target);
     if (td == null) {
       return -1;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java
index ded0140..a3a2a7a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImpl.java
@@ -20,7 +20,7 @@
 import com.google.gwtexpui.safehtml.client.SafeHtml;
 
 public class FancyFlexTableImpl {
-  public void resetHtml(final FlexTable myTable, final SafeHtml body) {
+  public void resetHtml(FlexTable myTable, SafeHtml body) {
     SafeHtml.setInnerHTML(getBodyElement(myTable), body);
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java
index a648412..3eae0f8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/FancyFlexTableImplIE8.java
@@ -23,7 +23,7 @@
 
 public class FancyFlexTableImplIE8 extends FancyFlexTableImpl {
   @Override
-  public void resetHtml(final FlexTable myTable, final SafeHtml bodyHtml) {
+  public void resetHtml(FlexTable myTable, SafeHtml bodyHtml) {
     final Element oldBody = getBodyElement(myTable);
     final Element newBody = parseBody(bodyHtml);
     assert newBody != null;
@@ -34,7 +34,7 @@
     DOM.appendChild(tableElem, newBody);
   }
 
-  private static Element parseBody(final SafeHtml body) {
+  private static Element parseBody(SafeHtml body) {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     b.openElement("table");
     b.append(body);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java
index 6e1fb09..f8e382a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingInlineHyperlink.java
@@ -18,8 +18,7 @@
 
   private String toHighlight;
 
-  public HighlightingInlineHyperlink(
-      final String text, final String token, final String toHighlight) {
+  public HighlightingInlineHyperlink(final String text, String token, String toHighlight) {
     super(text, token);
     this.toHighlight = toHighlight;
     highlight(text, toHighlight);
@@ -31,7 +30,7 @@
     highlight(text, toHighlight);
   }
 
-  private void highlight(final String text, final String toHighlight) {
+  private void highlight(String text, String toHighlight) {
     setHTML(Util.highlight(text, toHighlight));
   }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java
index 643c766..1e3be3f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HighlightingProjectsTable.java
@@ -21,13 +21,13 @@
 public class HighlightingProjectsTable extends ProjectsTable {
   private String toHighlight;
 
-  public void display(final ProjectMap projects, final String toHighlight) {
+  public void display(ProjectMap projects, String toHighlight) {
     this.toHighlight = toHighlight;
     super.display(projects);
   }
 
   @Override
-  protected void populate(final int row, final ProjectInfo k) {
+  protected void populate(int row, ProjectInfo k) {
     populateState(row, k);
     table.setWidget(
         row, ProjectsTable.C_NAME, new InlineHTML(Util.highlight(k.name(), toHighlight)));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java
index f8ad835..4ccfe9d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/HintTextBox.java
@@ -127,7 +127,7 @@
           addKeyDownHandler(
               new KeyDownHandler() {
                 @Override
-                public void onKeyDown(final KeyDownEvent event) {
+                public void onKeyDown(KeyDownEvent event) {
                   onKey(event.getNativeKeyCode());
                 }
               });
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java
index 6c28145..c35d097 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Hyperlink.java
@@ -35,7 +35,7 @@
    * @param token the history token to which it will link, which may not be null (use {@link Anchor}
    *     instead if you don't need history processing)
    */
-  public Hyperlink(final String text, final String token) {
+  public Hyperlink(String text, String token) {
     super(text, token);
   }
 
@@ -52,7 +52,7 @@
   }
 
   @Override
-  public void onBrowserEvent(final Event event) {
+  public void onBrowserEvent(Event event) {
     if (DOM.eventGetType(event) == Event.ONCLICK && impl.handleAsClick(event)) {
       event.preventDefault();
       go();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java
index 24f2887..a4edb5b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/InlineHyperlink.java
@@ -28,7 +28,7 @@
    * @param text the hyperlink's text
    * @param token the history token to which it will link
    */
-  public InlineHyperlink(final String text, final String token) {
+  public InlineHyperlink(String text, String token) {
     super(text, token);
   }
 
@@ -36,7 +36,7 @@
   public InlineHyperlink() {}
 
   @Override
-  public void onBrowserEvent(final Event event) {
+  public void onBrowserEvent(Event event) {
     if (DOM.eventGetType(event) == Event.ONCLICK && impl.handleAsClick(event)) {
       event.preventDefault();
       go();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java
index d08b6f9..d3db098 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuBar.java
@@ -32,20 +32,20 @@
     Gerrit.EVENT_BUS.addHandler(ScreenLoadEvent.TYPE, this);
   }
 
-  public void addItem(final String text, final Command imp) {
+  public void addItem(String text, Command imp) {
     add(new CommandMenuItem(text, imp));
   }
 
-  public void addItem(final CommandMenuItem i) {
+  public void addItem(CommandMenuItem i) {
     add(i);
   }
 
-  public void addItem(final LinkMenuItem i) {
+  public void addItem(LinkMenuItem i) {
     i.setMenuBar(this);
     add(i);
   }
 
-  public void insertItem(final LinkMenuItem i, int beforeIndex) {
+  public void insertItem(LinkMenuItem i, int beforeIndex) {
     i.setMenuBar(this);
     insert(i, beforeIndex);
   }
@@ -66,7 +66,7 @@
     return null;
   }
 
-  public void add(final Widget i) {
+  public void add(Widget i) {
     if (body.getWidgetCount() > 0) {
       final Widget p = body.getWidget(body.getWidgetCount() - 1);
       p.addStyleName(Gerrit.RESOURCES.css().linkMenuItemNotLast());
@@ -74,7 +74,7 @@
     body.add(i);
   }
 
-  public void insert(final Widget i, int beforeIndex) {
+  public void insert(Widget i, int beforeIndex) {
     if (body.getWidgetCount() == 0 || body.getWidgetCount() <= beforeIndex) {
       add(i);
       return;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuItem.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuItem.java
index 9cc91a0..8a8ab25 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuItem.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/LinkMenuItem.java
@@ -21,7 +21,7 @@
 public class LinkMenuItem extends InlineHyperlink implements ScreenLoadHandler {
   private LinkMenuBar bar;
 
-  public LinkMenuItem(final String text, final String targetHistoryToken) {
+  public LinkMenuItem(String text, String targetHistoryToken) {
     super(text, targetHistoryToken);
     setStyleName(Gerrit.RESOURCES.css().menuItem());
     Roles.getMenuitemRole().set(getElement());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java
index 2c614b5..0f28ddc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MenuScreen.java
@@ -54,22 +54,22 @@
   }
 
   @Override
-  protected void add(final Widget w) {
+  protected void add(Widget w) {
     body.add(w);
   }
 
-  protected void link(final String text, final String target) {
+  protected void link(String text, String target) {
     link(text, target, true);
   }
 
-  protected void link(final String text, final String target, final boolean visible) {
+  protected void link(String text, String target, boolean visible) {
     final LinkMenuItem item = new LinkMenuItem(text, target);
     item.setStyleName(Gerrit.RESOURCES.css().menuItem());
     item.setVisible(visible);
     menu.add(item);
   }
 
-  protected void setLinkVisible(final String token, final boolean visible) {
+  protected void setLinkVisible(String token, boolean visible) {
     final LinkMenuItem item = menu.find(token);
     item.setVisible(visible);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MoveDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MoveDialog.java
new file mode 100644
index 0000000..3821e93
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/MoveDialog.java
@@ -0,0 +1,107 @@
+// 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.client.ui;
+
+import com.google.gerrit.client.Gerrit;
+import com.google.gerrit.client.changes.Util;
+import com.google.gerrit.client.projects.BranchInfo;
+import com.google.gerrit.client.projects.ProjectApi;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gwt.core.client.JsArray;
+import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.SuggestBox;
+import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
+import com.google.gwtexpui.globalkey.client.GlobalKey;
+import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
+import java.util.ArrayList;
+import java.util.List;
+
+public abstract class MoveDialog extends TextAreaActionDialog {
+  private SuggestBox newBranch;
+  private List<BranchInfo> branches;
+
+  public MoveDialog(Project.NameKey project) {
+    super(Util.C.moveTitle(), Util.C.moveChangeMessage());
+    ProjectApi.getBranches(
+        project,
+        new GerritCallback<JsArray<BranchInfo>>() {
+          @Override
+          public void onSuccess(JsArray<BranchInfo> result) {
+            branches = Natives.asList(result);
+          }
+        });
+
+    newBranch =
+        new SuggestBox(
+            new HighlightSuggestOracle() {
+              @Override
+              protected void onRequestSuggestions(Request request, Callback done) {
+                List<BranchSuggestion> suggestions = new ArrayList<>();
+                for (BranchInfo b : branches) {
+                  if (b.ref().contains(request.getQuery())) {
+                    suggestions.add(new BranchSuggestion(b));
+                  }
+                }
+                done.onSuggestionsReady(request, new Response(suggestions));
+              }
+            });
+
+    newBranch.setWidth("100%");
+    newBranch.getElement().getStyle().setProperty("boxSizing", "border-box");
+    message.setCharacterWidth(70);
+
+    FlowPanel mwrap = new FlowPanel();
+    mwrap.setStyleName(Gerrit.RESOURCES.css().commentedActionMessage());
+    mwrap.add(newBranch);
+
+    panel.insert(mwrap, 0);
+    panel.insert(new SmallHeading(Util.C.headingMoveBranch()), 0);
+  }
+
+  @Override
+  public void center() {
+    super.center();
+    GlobalKey.dialog(this);
+    newBranch.setFocus(true);
+  }
+
+  public String getDestinationBranch() {
+    return newBranch.getText();
+  }
+
+  static class BranchSuggestion implements Suggestion {
+    private BranchInfo branch;
+
+    BranchSuggestion(BranchInfo branch) {
+      this.branch = branch;
+    }
+
+    @Override
+    public String getDisplayString() {
+      String refsHeads = "refs/heads/";
+      if (branch.ref().startsWith(refsHeads)) {
+        return branch.ref().substring(refsHeads.length());
+      }
+      return branch.ref();
+    }
+
+    @Override
+    public String getReplacementString() {
+      return branch.getShortName();
+    }
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
index 8975dda..b2306a6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/NavigationTable.java
@@ -42,7 +42,7 @@
     }
 
     @Override
-    public void onBrowserEvent(final Event event) {
+    public void onBrowserEvent(Event event) {
       switch (DOM.eventGetType(event)) {
         case Event.ONCLICK:
           {
@@ -73,9 +73,10 @@
     }
   }
 
-  @SuppressWarnings("serial")
   private static final LinkedHashMap<String, Object> savedPositions =
       new LinkedHashMap<String, Object>(10, 0.75f, true) {
+        private static final long serialVersionUID = 1L;
+
         @Override
         protected boolean removeEldestEntry(Entry<String, Object> eldest) {
           return size() >= 20;
@@ -198,11 +199,11 @@
     }
   }
 
-  protected void movePointerTo(final int newRow) {
+  protected void movePointerTo(int newRow) {
     movePointerTo(newRow, true);
   }
 
-  protected void movePointerTo(final int newRow, final boolean scroll) {
+  protected void movePointerTo(int newRow, boolean scroll) {
     final CellFormatter fmt = table.getCellFormatter();
     final boolean clear = 0 <= currentRow && currentRow < table.getRowCount();
     if (clear) {
@@ -223,7 +224,7 @@
     currentRow = newRow;
   }
 
-  protected void scrollIntoView(final Element tr) {
+  protected void scrollIntoView(Element tr) {
     if (!computedScrollType) {
       parentScrollPanel = null;
       Widget w = getParent();
@@ -280,14 +281,14 @@
     }
   }
 
-  protected void movePointerTo(final Object oldId) {
+  protected void movePointerTo(Object oldId) {
     final int row = findRow(oldId);
     if (0 <= row) {
       movePointerTo(row);
     }
   }
 
-  protected int findRow(final Object oldId) {
+  protected int findRow(Object oldId) {
     if (oldId != null) {
       final int max = table.getRowCount();
       for (int row = 0; row < max; row++) {
@@ -318,11 +319,11 @@
     }
   }
 
-  public void setSavePointerId(final String id) {
+  public void setSavePointerId(String id) {
     saveId = id;
   }
 
-  public void setRegisterKeys(final boolean on) {
+  public void setRegisterKeys(boolean on) {
     if (on && isAttached()) {
       if (regNavigation == null) {
         regNavigation = GlobalKey.add(this, keysNavigation);
@@ -375,7 +376,7 @@
     }
 
     @Override
-    public void onKeyPress(final KeyPressEvent event) {
+    public void onKeyPress(KeyPressEvent event) {
       ensurePointerVisible();
       onUp();
     }
@@ -387,7 +388,7 @@
     }
 
     @Override
-    public void onKeyPress(final KeyPressEvent event) {
+    public void onKeyPress(KeyPressEvent event) {
       ensurePointerVisible();
       onDown();
     }
@@ -399,7 +400,7 @@
     }
 
     @Override
-    public void onKeyPress(final KeyPressEvent event) {
+    public void onKeyPress(KeyPressEvent event) {
       ensurePointerVisible();
       onOpen();
     }
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 87de3b7..2c7fcd4 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
@@ -54,33 +54,33 @@
   // The first parameter to the contructors must be the FocusWidget to enable,
   // subsequent parameters are widgets to listenTo.
 
-  public OnEditEnabler(final FocusWidget w, final TextBoxBase tb) {
+  public OnEditEnabler(FocusWidget w, TextBoxBase tb) {
     this(w);
     originalValue = tb.getValue().trim();
     listenTo(tb);
   }
 
-  public OnEditEnabler(final FocusWidget w, final ListBox lb) {
+  public OnEditEnabler(FocusWidget w, ListBox lb) {
     this(w);
     listenTo(lb);
   }
 
-  public OnEditEnabler(final FocusWidget w, final CheckBox cb) {
+  public OnEditEnabler(FocusWidget w, CheckBox cb) {
     this(w);
     listenTo(cb);
   }
 
-  public OnEditEnabler(final FocusWidget w) {
+  public OnEditEnabler(FocusWidget w) {
     widget = w;
   }
 
-  public void updateOriginalValue(final TextBoxBase tb) {
+  public void updateOriginalValue(TextBoxBase tb) {
     originalValue = tb.getValue().trim();
   }
 
   // Register input widgets to be listened to
 
-  public void listenTo(final TextBoxBase tb) {
+  public void listenTo(TextBoxBase tb) {
     strings.put(tb, tb.getText().trim());
     tb.addKeyPressHandler(this);
 
@@ -105,44 +105,44 @@
     tb.addKeyDownHandler(this);
   }
 
-  public void listenTo(final ListBox lb) {
+  public void listenTo(ListBox lb) {
     lb.addChangeHandler(this);
   }
 
   @SuppressWarnings({"unchecked", "rawtypes"})
-  public void listenTo(final CheckBox cb) {
+  public void listenTo(CheckBox cb) {
     cb.addValueChangeHandler((ValueChangeHandler) this);
   }
 
   // Handlers
 
   @Override
-  public void onKeyPress(final KeyPressEvent e) {
+  public void onKeyPress(KeyPressEvent e) {
     on(e);
   }
 
   @Override
-  public void onKeyDown(final KeyDownEvent e) {
+  public void onKeyDown(KeyDownEvent e) {
     on(e);
   }
 
   @Override
-  public void onMouseUp(final MouseUpEvent e) {
+  public void onMouseUp(MouseUpEvent e) {
     on(e);
   }
 
   @Override
-  public void onChange(final ChangeEvent e) {
+  public void onChange(ChangeEvent e) {
     on(e);
   }
 
   @SuppressWarnings("rawtypes")
   @Override
-  public void onValueChange(final ValueChangeEvent e) {
+  public void onValueChange(ValueChangeEvent e) {
     on(e);
   }
 
-  private void on(final GwtEvent<?> e) {
+  private void on(GwtEvent<?> e) {
     if (widget.isEnabled()
         || !(e.getSource() instanceof FocusWidget)
         || !((FocusWidget) e.getSource()).isEnabled()) {
@@ -172,7 +172,7 @@
     }
   }
 
-  private void onTextBoxBase(final TextBoxBase tb) {
+  private void onTextBoxBase(TextBoxBase tb) {
     // The text appears to not get updated until the handlers complete.
     Scheduler.get()
         .scheduleDeferred(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java
index fab0cf7..7c45a20 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ParentProjectBox.java
@@ -38,11 +38,11 @@
     suggestBox.setVisibleLength(len);
   }
 
-  public void setProject(final Project.NameKey project) {
+  public void setProject(Project.NameKey project) {
     suggestOracle.setProject(project);
   }
 
-  public void setParentProject(final Project.NameKey parent) {
+  public void setParentProject(Project.NameKey parent) {
     suggestBox.setText(parent != null ? parent.get() : "");
   }
 
@@ -77,7 +77,7 @@
     }
 
     @Override
-    public void _onRequestSuggestions(Request req, final Callback callback) {
+    public void _onRequestSuggestions(Request req, Callback callback) {
       super._onRequestSuggestions(
           req,
           new Callback() {
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 cace84b..89bff71 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
@@ -52,7 +52,7 @@
   private boolean poppingUp;
   private boolean firstPopupLoad = true;
 
-  public void initPopup(final String popupText, final String currentPageLink) {
+  public void initPopup(String popupText, String currentPageLink) {
     createWidgets(popupText, currentPageLink);
     final FlowPanel pfp = new FlowPanel();
     pfp.add(filterPanel);
@@ -109,7 +109,7 @@
     return poppingUp;
   }
 
-  private void createWidgets(final String popupText, final String currentPageLink) {
+  private void createWidgets(String popupText, String currentPageLink) {
     filterPanel = new HorizontalPanel();
     filterPanel.setStyleName(Gerrit.RESOURCES.css().projectFilterPanel());
     final Label filterLabel =
@@ -135,13 +135,13 @@
     projectsTab =
         new HighlightingProjectsTable() {
           @Override
-          protected void movePointerTo(final int row, final boolean scroll) {
+          protected void movePointerTo(int row, boolean scroll) {
             super.movePointerTo(row, scroll);
             onMovePointerTo(getRowItem(row).name());
           }
 
           @Override
-          protected void onOpenRow(final int row) {
+          protected void onOpenRow(int row) {
             super.onOpenRow(row);
             openRow(getRowItem(row).name());
           }
@@ -161,7 +161,7 @@
     close.addClickHandler(
         new ClickHandler() {
           @Override
-          public void onClick(final ClickEvent event) {
+          public void onClick(ClickEvent event) {
             closePopup();
           }
         });
@@ -188,7 +188,7 @@
     popup.hide();
   }
 
-  public void setPreferredCoordinates(final int top, final int left) {
+  public void setPreferredCoordinates(int top, int left) {
     this.preferredTop = top;
     this.preferredLeft = left;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java
index 2767a05..f2ebf81 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectNameSuggestOracle.java
@@ -21,7 +21,7 @@
 /** Suggestion Oracle for Project.NameKey entities. */
 public class ProjectNameSuggestOracle extends SuggestAfterTypingNCharsOracle {
   @Override
-  public void _onRequestSuggestions(final Request req, final Callback callback) {
+  public void _onRequestSuggestions(Request req, Callback callback) {
     ProjectMap.suggest(
         req.getQuery(),
         req.getLimit(),
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
index 99d0e8e..ac89180 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/ProjectsTable.java
@@ -48,12 +48,12 @@
   }
 
   @Override
-  protected Object getRowItemKey(final ProjectInfo item) {
+  protected Object getRowItemKey(ProjectInfo item) {
     return item.name();
   }
 
   @Override
-  protected void onOpenRow(final int row) {
+  protected void onOpenRow(int row) {
     if (row > 0) {
       movePointerTo(row);
     }
@@ -84,7 +84,7 @@
     finishDisplay();
   }
 
-  protected void insert(final int row, final ProjectInfo k) {
+  protected void insert(int row, ProjectInfo k) {
     table.insertRow(row);
 
     applyDataRowStyle(row);
@@ -98,7 +98,7 @@
     populate(row, k);
   }
 
-  protected void populate(final int row, final ProjectInfo k) {
+  protected void populate(int row, ProjectInfo k) {
     populateState(row, k);
     table.setText(row, C_NAME, k.name());
     table.setText(row, C_DESCRIPTION, k.description());
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 f3dc6c3..e03ac46 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
@@ -42,7 +42,7 @@
   private final boolean sendEnabled;
 
   public RebaseDialog(
-      final String project,
+      final Project.NameKey project,
       final String branch,
       final Change.Id changeId,
       final boolean sendEnabled) {
@@ -88,7 +88,7 @@
           public void onClick(ClickEvent event) {
             if (changeParent.getValue()) {
               ChangeList.query(
-                  PageLinks.projectQuery(new Project.NameKey(project))
+                  PageLinks.projectQuery(project)
                       + " "
                       + PageLinks.op("branch", branch)
                       + " is:open -age:90d",
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 b0ee915..03ed899 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
@@ -96,12 +96,12 @@
     header.getCellFormatter().setWidth(0, Cols.FarEast.ordinal(), "100%");
   }
 
-  protected void setWindowTitle(final String text) {
+  protected void setWindowTitle(String text) {
     windowTitle = text;
     Gerrit.setWindowTitle(this, text);
   }
 
-  protected void setPageTitle(final String text) {
+  protected void setPageTitle(String text) {
     final String old = headerText.getText();
     if (text.isEmpty()) {
       header.setVisible(false);
@@ -118,23 +118,23 @@
     header.setVisible(value);
   }
 
-  public void setTitle(final Widget w) {
+  public void setTitle(Widget w) {
     titleWidget = w;
   }
 
-  protected void setTitleEast(final Widget w) {
+  protected void setTitleEast(Widget w) {
     header.setWidget(0, Cols.East.ordinal(), w);
   }
 
-  protected void setTitleFarEast(final Widget w) {
+  protected void setTitleFarEast(Widget w) {
     header.setWidget(0, Cols.FarEast.ordinal(), w);
   }
 
-  protected void setTitleWest(final Widget w) {
+  protected void setTitleWest(Widget w) {
     header.setWidget(0, Cols.West.ordinal(), w);
   }
 
-  protected void add(final Widget w) {
+  protected void add(Widget w) {
     body.add(w);
   }
 
@@ -142,7 +142,7 @@
     return body;
   }
 
-  protected void setTheme(final ThemeInfo t) {
+  protected void setTheme(ThemeInfo t) {
     theme = t;
   }
 
@@ -152,7 +152,7 @@
   }
 
   /** Set the history token for this screen. */
-  public void setToken(final String t) {
+  public void setToken(String t) {
     assert t != null && !t.isEmpty();
     token = t;
 
@@ -172,7 +172,7 @@
   }
 
   /** Set whether or not {@link Gerrit#isSignedIn()} must be true. */
-  public final void setRequiresSignIn(final boolean b) {
+  public final void setRequiresSignIn(boolean b) {
     requiresSignIn = b;
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SmallHeading.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SmallHeading.java
index b76c2fe..ea18d62 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SmallHeading.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/SmallHeading.java
@@ -22,7 +22,7 @@
     setStyleName(Gerrit.RESOURCES.css().smallHeading());
   }
 
-  public SmallHeading(final String text) {
+  public SmallHeading(String text) {
     this();
     setText(text);
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java
index 26026e1..41e3573 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Util.java
@@ -21,7 +21,7 @@
   public static final UIConstants C = GWT.create(UIConstants.class);
   public static final UIMessages M = GWT.create(UIMessages.class);
 
-  public static String highlight(final String text, final String toHighlight) {
+  public static String highlight(String text, String toHighlight) {
     final SafeHtmlBuilder b = new SafeHtmlBuilder();
     if (toHighlight == null || "".equals(toHighlight)) {
       b.append(text);
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java b/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
index ce91a46..cb1891e 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/addon/AddonInjector.java
@@ -68,7 +68,7 @@
     }
   }
 
-  private void beginLoading(final String addon) {
+  private void beginLoading(String addon) {
     pending++;
     Loader.injectScript(
         getAddonScriptUri(addon),
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java b/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java
index 582a3109..01bc7e2 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/lib/Loader.java
@@ -29,7 +29,7 @@
 public class Loader {
   private static native boolean isLibLoaded() /*-{ return $wnd.hasOwnProperty('CodeMirror'); }-*/;
 
-  static void initLibrary(final AsyncCallback<Void> cb) {
+  static void initLibrary(AsyncCallback<Void> cb) {
     if (isLibLoaded()) {
       cb.onSuccess(null);
       return;
@@ -53,7 +53,7 @@
     group.done();
   }
 
-  private static void injectCss(ExternalTextResource css, final AsyncCallback<Void> cb) {
+  private static void injectCss(ExternalTextResource css, AsyncCallback<Void> cb) {
     try {
       css.getText(
           new ResourceCallback<TextResource>() {
@@ -74,7 +74,7 @@
     }
   }
 
-  public static void injectScript(SafeUri js, final AsyncCallback<Void> callback) {
+  public static void injectScript(SafeUri js, AsyncCallback<Void> callback) {
     final ScriptElement[] script = new ScriptElement[1];
     script[0] =
         ScriptInjector.fromUrl(js.asString())
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java
index 7440102..5fda608 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInjector.java
@@ -70,7 +70,7 @@
     }
   }
 
-  private void beginLoading(final String mode) {
+  private void beginLoading(String mode) {
     pending++;
     Loader.injectScript(
         ModeInfo.getModeScriptUri(mode),
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 1dce708..23039d4 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/theme/ThemeLoader.java
@@ -74,7 +74,7 @@
 
   private static final EnumSet<Theme> loaded = EnumSet.of(Theme.DEFAULT);
 
-  public static final void loadTheme(final Theme theme, final AsyncCallback<Void> cb) {
+  public static final void loadTheme(Theme theme, AsyncCallback<Void> cb) {
     if (loaded.contains(theme)) {
       cb.onSuccess(null);
       return;
diff --git a/gerrit-gwtui/src/test/java/com/google/gerrit/client/change/ProjectChangeIdTest.java b/gerrit-gwtui/src/test/java/com/google/gerrit/client/change/ProjectChangeIdTest.java
new file mode 100644
index 0000000..1d47a82
--- /dev/null
+++ b/gerrit-gwtui/src/test/java/com/google/gerrit/client/change/ProjectChangeIdTest.java
@@ -0,0 +1,86 @@
+// 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.client.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class ProjectChangeIdTest {
+
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  @Test
+  public void emptyStringThrowsException() {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage(" is not a valid change identifier");
+    ProjectChangeId.create("");
+  }
+
+  @Test
+  public void noChangeIdThrowsException() {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("some/path is not a valid change identifier");
+    ProjectChangeId.create("some/path");
+  }
+
+  @Test
+  public void noChangeButProjectIdThrowsException() {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("some/+/path is not a valid change identifier");
+    ProjectChangeId.create("some/+/path");
+  }
+
+  @Test
+  public void project() {
+    assertThat(ProjectChangeId.create("test/+/123/some/path")).isEqualTo(result("test", 123));
+    assertThat(ProjectChangeId.create("test/+/123/some/path/")).isEqualTo(result("test", 123));
+    assertThat(ProjectChangeId.create("test/+/123/")).isEqualTo(result("test", 123));
+    assertThat(ProjectChangeId.create("test/+/123")).isEqualTo(result("test", 123));
+    // Numeric Project.NameKey
+    assertThat(ProjectChangeId.create("123/+/123")).isEqualTo(result("123", 123));
+    // Numeric Project.NameKey with ,edit as part of the name
+    assertThat(ProjectChangeId.create("123,edit/+/123")).isEqualTo(result("123,edit", 123));
+  }
+
+  @Test
+  public void noProject() {
+    assertThat(ProjectChangeId.create("123/some/path")).isEqualTo(result(null, 123));
+    assertThat(ProjectChangeId.create("123/some/path/")).isEqualTo(result(null, 123));
+    assertThat(ProjectChangeId.create("123/")).isEqualTo(result(null, 123));
+    assertThat(ProjectChangeId.create("123")).isEqualTo(result(null, 123));
+  }
+
+  @Test
+  public void editSuffix() {
+    assertThat(ProjectChangeId.create("123,edit/some/path")).isEqualTo(result(null, 123));
+    assertThat(ProjectChangeId.create("123,edit/")).isEqualTo(result(null, 123));
+    assertThat(ProjectChangeId.create("123,edit")).isEqualTo(result(null, 123));
+
+    assertThat(ProjectChangeId.create("test/+/123,edit/some/path")).isEqualTo(result("test", 123));
+    assertThat(ProjectChangeId.create("test/+/123,edit/")).isEqualTo(result("test", 123));
+    assertThat(ProjectChangeId.create("test/+/123,edit")).isEqualTo(result("test", 123));
+  }
+
+  private static ProjectChangeId result(@Nullable String project, int id) {
+    return new ProjectChangeId(
+        project == null ? null : new Project.NameKey(project), new Change.Id(id));
+  }
+}
diff --git a/gerrit-httpd/BUILD b/gerrit-httpd/BUILD
index d39e8f3..996b4a4 100644
--- a/gerrit-httpd/BUILD
+++ b/gerrit-httpd/BUILD
@@ -14,16 +14,18 @@
     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-index:query_exception",
         "//gerrit-launcher:launcher",
         "//gerrit-patch-jgit:server",
         "//gerrit-prettify:server",
         "//gerrit-reviewdb:server",
+        "//gerrit-server:metrics",
+        "//gerrit-server:receive",
         "//gerrit-server:server",
         "//gerrit-util-cli:cli",
         "//gerrit-util-http:http",
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java
index 0cd4efb..b8b0bc8 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -97,7 +97,7 @@
     }
 
     @Override
-    public void doFilter(ServletRequest req, ServletResponse res, final FilterChain last)
+    public void doFilter(ServletRequest req, ServletResponse res, FilterChain last)
         throws IOException, ServletException {
       final Iterator<AllRequestFilter> itr = filters.iterator();
       new FilterChain() {
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 9676cd3..6a19be7 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
@@ -19,15 +19,17 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.HostPageData;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.httpd.WebSessionManager.Key;
 import com.google.gerrit.httpd.WebSessionManager.Val;
+import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.reviewdb.client.Account;
 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.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Provider;
 import com.google.inject.servlet.RequestScoped;
@@ -56,12 +58,12 @@
   private CurrentUser user;
 
   protected CacheBasedWebSession(
-      final HttpServletRequest request,
-      final HttpServletResponse response,
-      final WebSessionManager manager,
-      final AuthConfig authConfig,
-      final Provider<AnonymousUser> anonymousProvider,
-      final IdentifiedUser.RequestFactory identified) {
+      HttpServletRequest request,
+      HttpServletResponse response,
+      WebSessionManager manager,
+      AuthConfig authConfig,
+      Provider<AnonymousUser> anonymousProvider,
+      IdentifiedUser.RequestFactory identified) {
     this.request = request;
     this.response = response;
     this.manager = manager;
@@ -70,31 +72,50 @@
     this.identified = identified;
 
     if (request.getRequestURI() == null || !GitSmartHttpTools.isGitClient(request)) {
-      String cookie = readCookie();
+      String cookie = readCookie(request);
       if (cookie != null) {
-        key = new Key(cookie);
-        val = manager.get(key);
-        if (val != null && val.needsCookieRefresh()) {
-          // Cookie is more than half old. Send the cookie again to the
-          // client with an updated expiration date.
-          val = manager.createVal(key, val);
+        authFromCookie(cookie);
+      } else {
+        String token;
+        try {
+          token = ParameterParser.getQueryParams(request).accessToken();
+        } catch (BadRequestException e) {
+          token = null;
         }
-
-        String token = request.getHeader(HostPageData.XSRF_HEADER_NAME);
-        if (val != null && token != null && token.equals(val.getAuth())) {
-          okPaths.add(AccessPath.REST_API);
+        if (token != null) {
+          authFromQueryParameter(token);
         }
       }
+      if (val != null && val.needsCookieRefresh()) {
+        // Session is more than half old; update cache entry with new expiration date.
+        val = manager.createVal(key, val);
+      }
     }
   }
 
-  private String readCookie() {
-    final Cookie[] all = request.getCookies();
+  private void authFromCookie(String cookie) {
+    key = new Key(cookie);
+    val = manager.get(key);
+    String token = request.getHeader(HostPageData.XSRF_HEADER_NAME);
+    if (val != null && token != null && token.equals(val.getAuth())) {
+      okPaths.add(AccessPath.REST_API);
+    }
+  }
+
+  private void authFromQueryParameter(String accessToken) {
+    key = new Key(accessToken);
+    val = manager.get(key);
+    if (val != null) {
+      okPaths.add(AccessPath.REST_API);
+    }
+  }
+
+  private static String readCookie(HttpServletRequest request) {
+    Cookie[] all = request.getCookies();
     if (all != null) {
-      for (final Cookie c : all) {
+      for (Cookie c : all) {
         if (ACCOUNT_COOKIE.equals(c.getName())) {
-          final String v = c.getValue();
-          return v != null && !"".equals(v) ? v : null;
+          return Strings.emptyToNull(c.getValue());
         }
       }
     }
@@ -229,7 +250,7 @@
     response.addCookie(outCookie);
   }
 
-  private static boolean isSecure(final HttpServletRequest req) {
+  private static boolean isSecure(HttpServletRequest req) {
     return req.isSecure() || "https".equals(req.getScheme());
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java
index 07893ba..7d261c8 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ContainerAuthFilter.java
@@ -17,9 +17,12 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static com.google.gerrit.extensions.api.lfs.LfsDefinitions.CONTENTTYPE_VND_GIT_LFS_JSON;
+import static com.google.gerrit.httpd.GerritAuthModule.NOT_AUTHORIZED_LFS_URL_REGEX;
 import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
 import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
 import com.google.gerrit.server.AccessPath;
@@ -31,6 +34,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Locale;
+import java.util.regex.Pattern;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -54,6 +58,9 @@
  */
 @Singleton
 class ContainerAuthFilter implements Filter {
+  private static final String LFS_AUTH_PREFIX = "Ssh: ";
+  private static final Pattern LFS_ENDPOINT = Pattern.compile(NOT_AUTHORIZED_LFS_URL_REGEX);
+
   private final DynamicItem<WebSession> session;
   private final AccountCache accountCache;
   private final Config config;
@@ -92,6 +99,11 @@
   private boolean verify(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     String username = RemoteUserUtil.getRemoteUser(req, loginHttpHeader);
     if (username == null) {
+      if (isLfsOverSshRequest(req)) {
+        // LFS-over-SSH auth request cannot be authorized by container
+        // therefore let it go through the filter
+        return true;
+      }
       rsp.sendError(SC_FORBIDDEN);
       return false;
     }
@@ -109,4 +121,12 @@
     ws.setAccessPathOk(AccessPath.REST_API, true);
     return true;
   }
+
+  private static boolean isLfsOverSshRequest(HttpServletRequest req) {
+    String hdr = req.getHeader(AUTHORIZATION);
+    return CONTENTTYPE_VND_GIT_LFS_JSON.equals(req.getContentType())
+        && !Strings.isNullOrEmpty(hdr)
+        && hdr.startsWith(LFS_AUTH_PREFIX)
+        && LFS_ENDPOINT.matcher(req.getRequestURI()).matches();
+  }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java
index 11342be..52cfde7 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CookieBase64.java
@@ -32,14 +32,14 @@
     enc[o] = '.';
   }
 
-  private static int fill(final char[] out, int o, final char f, final int l) {
+  private static int fill(char[] out, int o, char f, int l) {
     for (char c = f; c <= l; c++) {
       out[o++] = c;
     }
     return o;
   }
 
-  static String encode(final byte[] in) {
+  static String encode(byte[] in) {
     final StringBuilder out = new StringBuilder(in.length * 4 / 3);
     final int len2 = in.length - 2;
     int d = 0;
@@ -52,8 +52,7 @@
     return out.toString();
   }
 
-  private static void encode3to4(
-      final StringBuilder out, final byte[] in, final int inOffset, final int numSigBytes) {
+  private static void encode3to4(StringBuilder out, byte[] in, int inOffset, int numSigBytes) {
     //           1         2         3
     // 01234567890123456789012345678901 Bit position
     // --------000000001111111122222222 Array position from threeBytes
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java
index 825505c..26e4198 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/DirectChangeByCommit.java
@@ -9,6 +9,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -32,8 +33,7 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     String query = CharMatcher.is('/').trimTrailingFrom(req.getPathInfo());
     List<ChangeInfo> results;
     try {
@@ -46,7 +46,8 @@
     if (results.size() == 1) {
       // If exactly one change matches, link to that change.
       // TODO Link to a specific patch set, if one matched.
-      token = PageLinks.toChange(new Change.Id(results.iterator().next()._number));
+      ChangeInfo ci = results.iterator().next();
+      token = PageLinks.toChange(new Project.NameKey(ci.project), new Change.Id(ci._number));
     } else {
       // Otherwise, link to the query page.
       token = PageLinks.toChangeQuery(query);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritAuthModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritAuthModule.java
index c0ef207..253c220 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritAuthModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GerritAuthModule.java
@@ -24,7 +24,7 @@
 
 /** Configures filter for authenticating REST requests. */
 public class GerritAuthModule extends ServletModule {
-  private static final String NOT_AUTHORIZED_LFS_URL_REGEX = "^(?:(?!/a/))" + LFS_URL_WO_AUTH_REGEX;
+  static final String NOT_AUTHORIZED_LFS_URL_REGEX = "^(?:(?!/a/))" + LFS_URL_WO_AUTH_REGEX;
   private final AuthConfig authConfig;
 
   @Inject
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java
index bbcd977..103daba 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GetUserFilter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -28,35 +29,51 @@
 import javax.servlet.ServletException;
 import javax.servlet.ServletRequest;
 import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.lib.Config;
 
-/** Stores user as a request attribute, so servlets can access it outside of the request scope. */
+/**
+ * Stores user as a request attribute and/or response header, so servlets and reverse proxies can
+ * access it outside of the request/response scope.
+ */
 @Singleton
 public class GetUserFilter implements Filter {
 
-  public static final String REQ_ATTR_KEY = "User";
+  public static final String USER_ATTR_KEY = "User";
 
   public static class Module extends ServletModule {
 
-    private final boolean enabled;
+    private final boolean reqEnabled;
+    private final boolean resEnabled;
 
     @Inject
-    Module(@GerritServerConfig final Config cfg) {
-      enabled = cfg.getBoolean("http", "addUserAsRequestAttribute", true);
+    Module(@GerritServerConfig Config cfg) {
+      reqEnabled = cfg.getBoolean("http", "addUserAsRequestAttribute", true);
+      resEnabled = cfg.getBoolean("http", "addUserAsResponseHeader", false);
     }
 
     @Override
     protected void configureServlets() {
-      if (enabled) {
-        filter("/*").through(GetUserFilter.class);
+      if (resEnabled || reqEnabled) {
+        ImmutableMap.Builder<String, String> initParams = ImmutableMap.builder();
+        if (reqEnabled) {
+          initParams.put("reqEnabled", "");
+        }
+        if (resEnabled) {
+          initParams.put("resEnabled", "");
+        }
+        filter("/*").through(GetUserFilter.class, initParams.build());
       }
     }
   }
 
   private final Provider<CurrentUser> userProvider;
 
+  private boolean reqEnabled;
+  private boolean resEnabled;
+
   @Inject
-  GetUserFilter(final Provider<CurrentUser> userProvider) {
+  GetUserFilter(Provider<CurrentUser> userProvider) {
     this.userProvider = userProvider;
   }
 
@@ -65,11 +82,19 @@
       throws IOException, ServletException {
     CurrentUser user = userProvider.get();
     if (user != null && user.isIdentifiedUser()) {
+
       IdentifiedUser who = user.asIdentifiedUser();
+      String loggableName;
       if (who.getUserName() != null && !who.getUserName().isEmpty()) {
-        req.setAttribute(REQ_ATTR_KEY, who.getUserName());
+        loggableName = who.getUserName();
       } else {
-        req.setAttribute(REQ_ATTR_KEY, "a/" + who.getAccountId());
+        loggableName = "a/" + who.getAccountId();
+      }
+      if (reqEnabled) {
+        req.setAttribute(USER_ATTR_KEY, loggableName);
+      }
+      if (resEnabled && resp instanceof HttpServletResponse) {
+        ((HttpServletResponse) resp).addHeader(USER_ATTR_KEY, loggableName);
       }
     }
     chain.doFilter(req, resp);
@@ -79,5 +104,8 @@
   public void destroy() {}
 
   @Override
-  public void init(FilterConfig arg0) {}
+  public void init(FilterConfig arg0) {
+    reqEnabled = arg0.getInitParameter("reqEnabled") != null ? true : false;
+    resEnabled = arg0.getInitParameter("resEnabled") != null ? true : false;
+  }
 }
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 7a5956e..0da2f92 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
@@ -15,25 +15,30 @@
 package com.google.gerrit.httpd;
 
 import com.google.common.cache.Cache;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.audit.AuditService;
+import com.google.gerrit.audit.HttpAuditEvent;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.git.AsyncReceiveCommits;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.ReceiveCommits;
-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.UploadPackInitializer;
 import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.git.validators.UploadValidators;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.AbstractModule;
@@ -67,6 +72,7 @@
 import org.eclipse.jgit.transport.PreUploadHook;
 import org.eclipse.jgit.transport.PreUploadHookChain;
 import org.eclipse.jgit.transport.ReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.UploadPack;
 import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
 import org.eclipse.jgit.transport.resolver.RepositoryResolver;
@@ -80,7 +86,7 @@
   private static final long serialVersionUID = 1L;
 
   private static final String ATT_CONTROL = ProjectControl.class.getName();
-  private static final String ATT_RC = ReceiveCommits.class.getName();
+  private static final String ATT_ARC = AsyncReceiveCommits.class.getName();
   private static final String ID_CACHE = "adv_bases";
 
   public static final String URL_REGEX;
@@ -140,20 +146,52 @@
     addReceivePackFilter(receiveFilter);
   }
 
+  private static String extractWhat(HttpServletRequest request) {
+    StringBuilder commandName = new StringBuilder(request.getRequestURL());
+    if (request.getQueryString() != null) {
+      commandName.append("?").append(request.getQueryString());
+    }
+    return commandName.toString();
+  }
+
+  private static ListMultimap<String, String> extractParameters(HttpServletRequest request) {
+
+    ListMultimap<String, String> multiMap = ArrayListMultimap.create();
+    if (request.getQueryString() != null) {
+      request
+          .getParameterMap()
+          .forEach(
+              (k, v) -> {
+                for (int i = 0; i < v.length; i++) {
+                  multiMap.put(k, v[i]);
+                }
+              });
+    }
+    return multiMap;
+  }
+
   static class Resolver implements RepositoryResolver<HttpServletRequest> {
     private final GitRepositoryManager manager;
-    private final ProjectControl.Factory projectControlFactory;
+    private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> userProvider;
+    private final ProjectControl.GenericFactory projectControlFactory;
 
     @Inject
-    Resolver(GitRepositoryManager manager, ProjectControl.Factory projectControlFactory) {
+    Resolver(
+        GitRepositoryManager manager,
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> userProvider,
+        ProjectControl.GenericFactory projectControlFactory) {
       this.manager = manager;
+      this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
       this.projectControlFactory = projectControlFactory;
     }
 
     @Override
     public Repository open(HttpServletRequest req, String projectName)
         throws RepositoryNotFoundException, ServiceNotAuthorizedException,
-            ServiceNotEnabledException {
+            ServiceNotEnabledException, ServiceMayNotContinueException {
       while (projectName.endsWith("/")) {
         projectName = projectName.substring(0, projectName.length() - 1);
       }
@@ -168,28 +206,31 @@
         }
       }
 
-      final ProjectControl pc;
-      try {
-        pc = projectControlFactory.controlFor(new Project.NameKey(projectName));
-      } catch (NoSuchProjectException err) {
-        throw new RepositoryNotFoundException(projectName);
-      }
-
-      CurrentUser user = pc.getUser();
+      CurrentUser user = userProvider.get();
       user.setAccessPath(AccessPath.GIT);
 
-      if (!pc.isVisible()) {
-        if (user instanceof AnonymousUser) {
-          throw new ServiceNotAuthorizedException();
-        }
-        throw new ServiceNotEnabledException();
-      }
-      req.setAttribute(ATT_CONTROL, pc);
-
       try {
-        return manager.openRepository(pc.getProject().getNameKey());
-      } catch (IOException e) {
-        throw new RepositoryNotFoundException(pc.getProject().getNameKey().get(), e);
+        Project.NameKey nameKey = new Project.NameKey(projectName);
+        ProjectControl pc;
+        try {
+          pc = projectControlFactory.controlFor(nameKey, user);
+        } catch (NoSuchProjectException err) {
+          throw new RepositoryNotFoundException(projectName);
+        }
+        req.setAttribute(ATT_CONTROL, pc);
+
+        try {
+          permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+        } catch (AuthException e) {
+          if (user instanceof AnonymousUser) {
+            throw new ServiceNotAuthorizedException();
+          }
+          throw new ServiceNotEnabledException(e.getMessage());
+        }
+
+        return manager.openRepository(nameKey);
+      } catch (IOException | PermissionBackendException err) {
+        throw new ServiceMayNotContinueException(projectName + " unavailable", err);
       }
     }
   }
@@ -198,15 +239,18 @@
     private final TransferConfig config;
     private final DynamicSet<PreUploadHook> preUploadHooks;
     private final DynamicSet<PostUploadHook> postUploadHooks;
+    private final DynamicSet<UploadPackInitializer> uploadPackInitializers;
 
     @Inject
     UploadFactory(
         TransferConfig tc,
         DynamicSet<PreUploadHook> preUploadHooks,
-        DynamicSet<PostUploadHook> postUploadHooks) {
+        DynamicSet<PostUploadHook> postUploadHooks,
+        DynamicSet<UploadPackInitializer> uploadPackInitializers) {
       this.config = tc;
       this.preUploadHooks = preUploadHooks;
       this.postUploadHooks = postUploadHooks;
+      this.uploadPackInitializers = uploadPackInitializers;
     }
 
     @Override
@@ -216,29 +260,33 @@
       up.setTimeout(config.getTimeout());
       up.setPreUploadHook(PreUploadHookChain.newChain(Lists.newArrayList(preUploadHooks)));
       up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
+      ProjectControl pc = (ProjectControl) req.getAttribute(ATT_CONTROL);
+      for (UploadPackInitializer initializer : uploadPackInitializers) {
+        initializer.init(pc.getProject().getNameKey(), up);
+      }
       return up;
     }
   }
 
   static class UploadFilter implements Filter {
-    private final Provider<ReviewDb> db;
-    private final TagCache tagCache;
-    private final ChangeNotes.Factory changeNotesFactory;
-    @Nullable private final SearchingChangeCacheImpl changeCache;
+    private final VisibleRefFilter.Factory refFilterFactory;
     private final UploadValidators.Factory uploadValidatorsFactory;
+    private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> userProvider;
+    private final AuditService auditService;
 
     @Inject
     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;
+        VisibleRefFilter.Factory refFilterFactory,
+        UploadValidators.Factory uploadValidatorsFactory,
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> userProvider,
+        AuditService auditService) {
+      this.refFilterFactory = refFilterFactory;
       this.uploadValidatorsFactory = uploadValidatorsFactory;
+      this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
+      this.auditService = auditService;
     }
 
     @Override
@@ -249,23 +297,43 @@
       ProjectControl pc = (ProjectControl) request.getAttribute(ATT_CONTROL);
       UploadPack up = (UploadPack) request.getAttribute(ServletUtils.ATTRIBUTE_HANDLER);
 
-      if (!pc.canRunUploadPack()) {
+      try {
+        permissionBackend
+            .user(pc.getUser())
+            .project(pc.getProject().getNameKey())
+            .check(ProjectPermission.RUN_UPLOAD_PACK);
+      } catch (AuthException e) {
         GitSmartHttpTools.sendError(
             (HttpServletRequest) request,
             (HttpServletResponse) response,
             HttpServletResponse.SC_FORBIDDEN,
             "upload-pack not permitted on this server");
         return;
+      } catch (PermissionBackendException e) {
+        throw new ServletException(e);
+      } finally {
+        HttpServletRequest httpRequest = (HttpServletRequest) request;
+        HttpServletResponse httpResponse = (HttpServletResponse) response;
+        auditService.dispatch(
+            new HttpAuditEvent(
+                httpRequest.getSession().getId(),
+                userProvider.get(),
+                extractWhat(httpRequest),
+                TimeUtil.nowMs(),
+                extractParameters(httpRequest),
+                httpRequest.getMethod(),
+                httpRequest,
+                httpResponse.getStatus(),
+                httpResponse));
       }
+
       // We use getRemoteHost() here instead of getRemoteAddr() because REMOTE_ADDR
       // may have been overridden by a proxy server -- we'll try to avoid this.
       UploadValidators uploadValidators =
           uploadValidatorsFactory.create(pc.getProject(), repo, request.getRemoteHost());
       up.setPreUploadHook(
           PreUploadHookChain.newChain(Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
-      up.setAdvertiseRefsHook(
-          new VisibleRefFilter(
-              tagCache, changeNotesFactory, changeCache, repo, pc, db.get(), true));
+      up.setAdvertiseRefsHook(refFilterFactory.create(pc.getProjectState(), repo));
 
       next.doFilter(request, response);
     }
@@ -295,11 +363,9 @@
         throw new ServiceNotAuthorizedException();
       }
 
-      ReceiveCommits rc = factory.create(pc, db).getReceiveCommits();
-      rc.init();
-
-      ReceivePack rp = rc.getReceivePack();
-      req.setAttribute(ATT_RC, rc);
+      AsyncReceiveCommits arc = factory.create(pc, db, null, ImmutableSetMultimap.of());
+      ReceivePack rp = arc.getReceivePack();
+      req.setAttribute(ATT_ARC, arc);
       return rp;
     }
   }
@@ -314,10 +380,20 @@
 
   static class ReceiveFilter implements Filter {
     private final Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache;
+    private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> userProvider;
+    private final AuditService auditService;
 
     @Inject
-    ReceiveFilter(@Named(ID_CACHE) Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache) {
+    ReceiveFilter(
+        @Named(ID_CACHE) Cache<AdvertisedObjectsCacheKey, Set<ObjectId>> cache,
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> userProvider,
+        AuditService auditService) {
       this.cache = cache;
+      this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
+      this.auditService = auditService;
     }
 
     @Override
@@ -325,22 +401,43 @@
         throws IOException, ServletException {
       boolean isGet = "GET".equalsIgnoreCase(((HttpServletRequest) request).getMethod());
 
-      ReceiveCommits rc = (ReceiveCommits) request.getAttribute(ATT_RC);
-      ReceivePack rp = rc.getReceivePack();
+      AsyncReceiveCommits arc = (AsyncReceiveCommits) request.getAttribute(ATT_ARC);
+      ReceivePack rp = arc.getReceivePack();
       rp.getAdvertiseRefsHook().advertiseRefs(rp);
       ProjectControl pc = (ProjectControl) request.getAttribute(ATT_CONTROL);
       Project.NameKey projectName = pc.getProject().getNameKey();
 
-      if (!pc.canRunReceivePack()) {
+      try {
+        permissionBackend
+            .user(pc.getUser())
+            .project(pc.getProject().getNameKey())
+            .check(ProjectPermission.RUN_RECEIVE_PACK);
+      } catch (AuthException e) {
         GitSmartHttpTools.sendError(
             (HttpServletRequest) request,
             (HttpServletResponse) response,
             HttpServletResponse.SC_FORBIDDEN,
             "receive-pack not permitted on this server");
         return;
+      } catch (PermissionBackendException e) {
+        throw new RuntimeException(e);
+      } finally {
+        HttpServletRequest httpRequest = (HttpServletRequest) request;
+        HttpServletResponse httpResponse = (HttpServletResponse) response;
+        auditService.dispatch(
+            new HttpAuditEvent(
+                httpRequest.getSession().getId(),
+                userProvider.get(),
+                extractWhat(httpRequest),
+                TimeUtil.nowMs(),
+                extractParameters(httpRequest),
+                httpRequest.getMethod(),
+                httpRequest,
+                httpResponse.getStatus(),
+                httpResponse));
       }
 
-      final Capable s = rc.canUpload();
+      Capable s = arc.canUpload();
       if (s != Capable.OK) {
         GitSmartHttpTools.sendError(
             (HttpServletRequest) request,
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 6411ee5..3dd31d9 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
@@ -28,12 +28,12 @@
   private Provider<HttpServletRequest> requestProvider;
 
   @Inject
-  HttpCanonicalWebUrlProvider(@GerritServerConfig final Config config) {
+  HttpCanonicalWebUrlProvider(@GerritServerConfig Config config) {
     super(config);
   }
 
   @Inject(optional = true)
-  public void setHttpServletRequest(final Provider<HttpServletRequest> hsr) {
+  public void setHttpServletRequest(Provider<HttpServletRequest> hsr) {
     requestProvider = hsr;
   }
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java
index 00c18af..eb77a30 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpLogoutServlet.java
@@ -42,18 +42,17 @@
 
   @Inject
   protected HttpLogoutServlet(
-      final AuthConfig authConfig,
-      final DynamicItem<WebSession> webSession,
-      @CanonicalWebUrl @Nullable final Provider<String> urlProvider,
-      final AuditService audit) {
+      AuthConfig authConfig,
+      DynamicItem<WebSession> webSession,
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      AuditService audit) {
     this.webSession = webSession;
     this.urlProvider = urlProvider;
     this.logoutUrl = authConfig.getLogoutURL();
     this.audit = audit;
   }
 
-  protected void doLogout(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doLogout(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     webSession.get().logout();
     if (logoutUrl != null) {
       rsp.sendRedirect(logoutUrl);
@@ -73,8 +72,7 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
 
     final String sid = webSession.get().getSessionId();
     final CurrentUser currentUser = webSession.get().getUser();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java
index 2dedd86..e023644 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/HttpRemotePeerProvider.java
@@ -29,7 +29,7 @@
   private final HttpServletRequest req;
 
   @Inject
-  HttpRemotePeerProvider(final HttpServletRequest r) {
+  HttpRemotePeerProvider(HttpServletRequest r) {
     req = r;
   }
 
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 87de003..7f78385 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
@@ -22,7 +22,7 @@
 public class LoginUrlToken {
   private static final String DEFAULT_TOKEN = '#' + PageLinks.MINE;
 
-  public static String getToken(final HttpServletRequest req) {
+  public static String getToken(HttpServletRequest req) {
     String token = req.getPathInfo();
     if (Strings.isNullOrEmpty(token)) {
       return DEFAULT_TOKEN;
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 3358976..b374cb4 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
@@ -176,7 +176,7 @@
     }
   }
 
-  private boolean succeedAuthentication(final AccountState who) {
+  private boolean succeedAuthentication(AccountState who) {
     setUserIdentified(who.getAccount().getId());
     return true;
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/QueryDocumentationFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
new file mode 100644
index 0000000..7a89b3b
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/QueryDocumentationFilter.java
@@ -0,0 +1,72 @@
+// 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;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
+import com.google.gerrit.server.documentation.QueryDocumentationExecutor.DocQueryException;
+import com.google.gerrit.server.documentation.QueryDocumentationExecutor.DocResult;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+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.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class QueryDocumentationFilter implements Filter {
+  private final Logger log = LoggerFactory.getLogger(QueryDocumentationFilter.class);
+
+  private final QueryDocumentationExecutor searcher;
+
+  @Inject
+  QueryDocumentationFilter(QueryDocumentationExecutor searcher) {
+    this.searcher = searcher;
+  }
+
+  @Override
+  public void init(FilterConfig filterConfig) {}
+
+  @Override
+  public void destroy() {}
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    HttpServletRequest req = (HttpServletRequest) request;
+    if ("GET".equals(req.getMethod()) && !Strings.isNullOrEmpty(req.getParameter("q"))) {
+      HttpServletResponse rsp = (HttpServletResponse) response;
+      try {
+        List<DocResult> result = searcher.doQuery(request.getParameter("q"));
+        RestApiServlet.replyJson(req, rsp, ImmutableListMultimap.of(), result);
+      } catch (DocQueryException e) {
+        log.error("Doc search failed:", e);
+        rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      }
+    } else {
+      chain.doFilter(request, response);
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java
index 548db48..6e02796 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RequestContextFilter.java
@@ -48,9 +48,7 @@
 
   @Inject
   RequestContextFilter(
-      final Provider<RequestCleanup> r,
-      final Provider<HttpRequestContext> c,
-      final ThreadLocalRequestContext l) {
+      Provider<RequestCleanup> r, Provider<HttpRequestContext> c, ThreadLocalRequestContext l) {
     cleanup = r;
     requestContext = c;
     local = l;
@@ -63,8 +61,7 @@
   public void destroy() {}
 
   @Override
-  public void doFilter(
-      final ServletRequest request, final ServletResponse response, final FilterChain chain)
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
       throws IOException, ServletException {
     RequestContext old = local.setContext(requestContext.get());
     try {
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 4bdd1f0..d8e6f84 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
@@ -52,7 +52,7 @@
   private final Provider<String> urlProvider;
 
   @Inject
-  RequireSslFilter(@CanonicalWebUrl @Nullable final Provider<String> urlProvider) {
+  RequireSslFilter(@CanonicalWebUrl @Nullable Provider<String> urlProvider) {
     this.urlProvider = urlProvider;
   }
 
@@ -63,8 +63,7 @@
   public void destroy() {}
 
   @Override
-  public void doFilter(
-      final ServletRequest request, final ServletResponse response, final FilterChain chain)
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
       throws IOException, ServletException {
     final HttpServletRequest req = (HttpServletRequest) request;
     final HttpServletResponse rsp = (HttpServletResponse) response;
@@ -91,11 +90,11 @@
     }
   }
 
-  private static boolean isSecure(final HttpServletRequest req) {
+  private static boolean isSecure(HttpServletRequest req) {
     return "https".equals(req.getScheme()) || req.isSecure();
   }
 
-  private static boolean isLocalHost(final HttpServletRequest req) {
+  private static boolean isLocalHost(HttpServletRequest req) {
     return "localhost".equals(req.getServerName()) || "127.0.0.1".equals(req.getServerName());
   }
 }
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 4862a70..9940cd9 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
@@ -19,14 +19,16 @@
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
 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.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 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;
 import java.io.IOException;
@@ -38,6 +40,7 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -54,20 +57,20 @@
     }
   }
 
-  private final Provider<ReviewDb> db;
   private final boolean enabled;
   private final DynamicItem<WebSession> session;
+  private final PermissionBackend permissionBackend;
   private final AccountResolver accountResolver;
 
   @Inject
   RunAsFilter(
-      Provider<ReviewDb> db,
       AuthConfig config,
       DynamicItem<WebSession> session,
+      PermissionBackend permissionBackend,
       AccountResolver accountResolver) {
-    this.db = db;
     this.enabled = config.isRunAsEnabled();
     this.session = session;
+    this.permissionBackend = permissionBackend;
     this.accountResolver = accountResolver;
   }
 
@@ -85,18 +88,26 @@
       }
 
       CurrentUser self = session.get().getUser();
-      if (!self.getCapabilities().canRunAs()
+      try {
+        if (!self.isIdentifiedUser()) {
           // Always disallow for anonymous users, even if permitted by the ACL,
           // because that would be crazy.
-          || !self.isIdentifiedUser()) {
+          throw new AuthException("denied");
+        }
+        permissionBackend.user(self).check(GlobalPermission.RUN_AS);
+      } catch (AuthException e) {
         replyError(req, res, SC_FORBIDDEN, "not permitted to use " + RUN_AS, null);
         return;
+      } catch (PermissionBackendException e) {
+        log.warn("cannot check runAs", e);
+        replyError(req, res, SC_INTERNAL_SERVER_ERROR, RUN_AS + " unavailable", null);
+        return;
       }
 
       Account target;
       try {
-        target = accountResolver.find(db.get(), runas);
-      } catch (OrmException e) {
+        target = accountResolver.find(runas);
+      } catch (OrmException | IOException | ConfigInvalidException e) {
         log.warn("cannot resolve account for " + RUN_AS, e);
         replyError(req, res, SC_INTERNAL_SERVER_ERROR, "cannot resolve " + RUN_AS, e);
         return;
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 409e978..3ab0d79 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
@@ -30,7 +30,6 @@
 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.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.AuthConfig;
@@ -113,7 +112,9 @@
     serveRegex("^/(?:a/)?groups/(.*)?$").with(GroupsRestApiServlet.class);
     serveRegex("^/(?:a/)?projects/(.*)?$").with(ProjectsRestApiServlet.class);
 
-    filter("/Documentation/").through(QueryDocumentationFilter.class);
+    serveRegex("^/Documentation$").with(redirectDocumentation());
+    serveRegex("^/Documentation/$").with(redirectDocumentation());
+    filter("/Documentation/*").through(QueryDocumentationFilter.class);
   }
 
   private Key<HttpServlet> notFound() {
@@ -122,8 +123,7 @@
           private static final long serialVersionUID = 1L;
 
           @Override
-          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-              throws IOException {
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
             rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
           }
         });
@@ -135,21 +135,19 @@
           private static final long serialVersionUID = 1L;
 
           @Override
-          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-              throws IOException {
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
             toGerrit(req.getRequestURI().substring(req.getContextPath().length()), req, rsp);
           }
         });
   }
 
-  private Key<HttpServlet> screen(final String target) {
+  private Key<HttpServlet> screen(String target) {
     return key(
         new HttpServlet() {
           private static final long serialVersionUID = 1L;
 
           @Override
-          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-              throws IOException {
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
             toGerrit(target, req, rsp);
           }
         });
@@ -161,8 +159,7 @@
           private static final long serialVersionUID = 1L;
 
           @Override
-          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-              throws IOException {
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
             final String token = req.getPathInfo().substring(1);
             toGerrit(token, req, rsp);
           }
@@ -175,15 +172,17 @@
           private static final long serialVersionUID = 1L;
 
           @Override
-          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-              throws IOException {
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
             try {
               String idString = req.getPathInfo();
               if (idString.endsWith("/")) {
                 idString = idString.substring(0, idString.length() - 1);
               }
               Change.Id id = Change.Id.parse(idString);
-              toGerrit(PageLinks.toChange(id), req, rsp);
+              // User accessed Gerrit with /1234, so we have no project yet.
+              // TODO(hiesel) Replace with a preflight request to obtain project before we deprecate
+              // the numeric change id.
+              toGerrit(PageLinks.toChange(null, id), req, rsp);
             } catch (IllegalArgumentException err) {
               rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
             }
@@ -225,20 +224,19 @@
         });
   }
 
-  private Key<HttpServlet> query(final String query) {
+  private Key<HttpServlet> query(String query) {
     return key(
         new HttpServlet() {
           private static final long serialVersionUID = 1L;
 
           @Override
-          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-              throws IOException {
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
             toGerrit(PageLinks.toChangeQuery(query), req, rsp);
           }
         });
   }
 
-  private Key<HttpServlet> key(final HttpServlet servlet) {
+  private Key<HttpServlet> key(HttpServlet servlet) {
     final Key<HttpServlet> srv = Key.get(HttpServlet.class, UniqueAnnotations.create());
     bind(srv)
         .toProvider(
@@ -258,16 +256,27 @@
           private static final long serialVersionUID = 1L;
 
           @Override
-          protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-              throws IOException {
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
             String path = String.format("/register%s", slash ? req.getPathInfo() : "");
             toGerrit(path, req, rsp);
           }
         });
   }
 
-  static void toGerrit(
-      final String target, final HttpServletRequest req, final HttpServletResponse rsp)
+  private Key<HttpServlet> redirectDocumentation() {
+    return key(
+        new HttpServlet() {
+          private static final long serialVersionUID = 1L;
+
+          @Override
+          protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+            String path = "/Documentation/index.html";
+            toGerrit(path, req, rsp);
+          }
+        });
+  }
+
+  static void toGerrit(String target, HttpServletRequest req, HttpServletResponse rsp)
       throws IOException {
     final StringBuilder url = new StringBuilder();
     url.append(req.getContextPath());
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 9967af6..538d605 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
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GitwebCgiConfig;
-import com.google.gerrit.server.git.AsyncReceiveCommits;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.util.GuiceRequestScopePropagator;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
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 f1600bc..e476f15 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
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
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 bc01319..13152f6 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
@@ -30,7 +30,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -55,7 +55,7 @@
   private final Cache<String, Val> self;
 
   @Inject
-  WebSessionManager(@GerritServerConfig Config cfg, @Assisted final Cache<String, Val> cache) {
+  WebSessionManager(@GerritServerConfig Config cfg, @Assisted Cache<String, Val> cache) {
     prng = new SecureRandom();
     self = cache;
 
@@ -76,11 +76,11 @@
     }
   }
 
-  Key createKey(final Account.Id who) {
+  Key createKey(Account.Id who) {
     return new Key(newUniqueToken(who));
   }
 
-  private String newUniqueToken(final Account.Id who) {
+  private String newUniqueToken(Account.Id who) {
     try {
       final int nonceLen = 20;
       final ByteArrayOutputStream buf;
@@ -135,7 +135,7 @@
     return val;
   }
 
-  int getCookieAge(final Val val) {
+  int getCookieAge(Val val) {
     if (val.isPersistentCookie()) {
       // Client may store the cookie until we would remove it from our
       // own cache, after which it will certainly be invalid.
@@ -150,7 +150,7 @@
     return -1;
   }
 
-  Val get(final Key key) {
+  Val get(Key key) {
     Val val = self.getIfPresent(key.token);
     if (val != null && val.expiresAt <= nowMs()) {
       self.invalidate(key.token);
@@ -159,14 +159,14 @@
     return val;
   }
 
-  void destroy(final Key key) {
+  void destroy(Key key) {
     self.invalidate(key.token);
   }
 
   static final class Key {
     private transient String token;
 
-    Key(final String t) {
+    Key(String t) {
       token = t;
     }
 
@@ -217,7 +217,15 @@
       return expiresAt;
     }
 
-    Account.Id getAccountId() {
+    /**
+     * Parse an Account.Id.
+     *
+     * <p>This is public so that plugins that implement a web session, can also implement a way to
+     * clear per user sessions.
+     *
+     * @return account ID.
+     */
+    public Account.Id getAccountId() {
       return accountId;
     }
 
@@ -241,7 +249,7 @@
       return persistentCookie;
     }
 
-    private void writeObject(final ObjectOutputStream out) throws IOException {
+    private void writeObject(ObjectOutputStream out) throws IOException {
       writeVarInt32(out, 1);
       writeVarInt32(out, accountId.get());
 
@@ -272,7 +280,7 @@
       writeVarInt32(out, 0);
     }
 
-    private void readObject(final ObjectInputStream in) throws IOException {
+    private void readObject(ObjectInputStream in) throws IOException {
       PARSE:
       for (; ; ) {
         final int tag = readVarInt32(in);
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 b7c6be3..a441901 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
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.httpd.auth.become;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -25,63 +25,74 @@
 import com.google.gerrit.httpd.template.SiteHeaderFooter;
 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.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 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;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.Writer;
 import java.util.List;
+import java.util.Optional;
 import java.util.UUID;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 
-@SuppressWarnings("serial")
 @Singleton
 class BecomeAnyAccountLoginServlet extends HttpServlet {
-  private final SchemaFactory<ReviewDb> schema;
+  private static final long serialVersionUID = 1L;
+
   private final DynamicItem<WebSession> webSession;
+  private final SchemaFactory<ReviewDb> schema;
+  private final Accounts accounts;
+  private final AccountCache accountCache;
   private final AccountManager accountManager;
   private final SiteHeaderFooter headers;
-  private final InternalAccountQuery accountQuery;
+  private final Provider<InternalAccountQuery> queryProvider;
 
   @Inject
   BecomeAnyAccountLoginServlet(
       DynamicItem<WebSession> ws,
       SchemaFactory<ReviewDb> sf,
+      Accounts a,
+      AccountCache ac,
       AccountManager am,
       SiteHeaderFooter shf,
-      InternalAccountQuery aq) {
+      Provider<InternalAccountQuery> qp) {
     webSession = ws;
     schema = sf;
+    accounts = a;
+    accountCache = ac;
     accountManager = am;
     headers = shf;
-    accountQuery = aq;
+    queryProvider = qp;
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
       throws IOException, ServletException {
     doPost(req, rsp);
   }
 
   @Override
-  protected void doPost(final HttpServletRequest req, final HttpServletResponse rsp)
+  protected void doPost(HttpServletRequest req, HttpServletResponse rsp)
       throws IOException, ServletException {
     CacheHeaders.setNotCacheable(rsp);
 
@@ -149,8 +160,8 @@
 
     Element userlistElement = HtmlDomUtil.find(doc, "userlist");
     try (ReviewDb db = schema.open()) {
-      ResultSet<Account> accounts = db.accounts().firstNById(100);
-      for (Account a : accounts) {
+      for (Account.Id accountId : accounts.firstNIds(100)) {
+        Account a = accountCache.get(accountId).getAccount();
         String displayName;
         if (a.getUserName() != null) {
           displayName = a.getUserName();
@@ -159,7 +170,7 @@
         } else if (a.getPreferredEmail() != null) {
           displayName = a.getPreferredEmail();
         } else {
-          displayName = a.getId().toString();
+          displayName = accountId.toString();
         }
 
         Element linkElement = doc.createElement("a");
@@ -173,7 +184,7 @@
     return HtmlDomUtil.toUTF8(doc);
   }
 
-  private AuthResult auth(final Account account) {
+  private AuthResult auth(Account account) {
     if (account != null) {
       return new AuthResult(account.getId(), null, false);
     }
@@ -187,9 +198,10 @@
     return null;
   }
 
-  private AuthResult byUserName(final String userName) {
+  private AuthResult byUserName(String userName) {
     try {
-      List<AccountState> accountStates = accountQuery.byExternalId(SCHEME_USERNAME, userName);
+      List<AccountState> accountStates =
+          queryProvider.get().byExternalId(SCHEME_USERNAME, userName);
       if (accountStates.isEmpty()) {
         getServletContext().log("No accounts with username " + userName + " found");
         return null;
@@ -205,26 +217,29 @@
     }
   }
 
-  private AuthResult byPreferredEmail(final String email) {
+  private AuthResult byPreferredEmail(String email) {
     try (ReviewDb db = schema.open()) {
-      List<Account> matches = db.accounts().byPreferredEmail(email).toList();
-      return matches.size() == 1 ? auth(matches.get(0)) : null;
+      Optional<Account> match =
+          queryProvider.get().byPreferredEmail(email).stream()
+              .map(AccountState::getAccount)
+              .findFirst();
+      return match.isPresent() ? auth(match.get()) : null;
     } catch (OrmException e) {
       getServletContext().log("cannot query database", e);
       return null;
     }
   }
 
-  private AuthResult byAccountId(final String idStr) {
+  private AuthResult byAccountId(String idStr) {
     final Account.Id id;
     try {
       id = Account.Id.parse(idStr);
     } catch (NumberFormatException nfe) {
       return null;
     }
-    try (ReviewDb db = schema.open()) {
-      return auth(db.accounts().get(id));
-    } catch (OrmException e) {
+    try {
+      return auth(accounts.get(id));
+    } catch (IOException | ConfigInvalidException e) {
       getServletContext().log("cannot query database", e);
       return null;
     }
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 5a0ed71..c7229bc 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
@@ -17,7 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -26,7 +26,7 @@
 import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.raw.HostPageServlet;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
@@ -66,8 +66,7 @@
   private final boolean userNameToLowerCase;
 
   @Inject
-  HttpAuthFilter(final DynamicItem<WebSession> webSession, final AuthConfig authConfig)
-      throws IOException {
+  HttpAuthFilter(DynamicItem<WebSession> webSession, AuthConfig authConfig) throws IOException {
     this.sessionProvider = webSession;
 
     final String pageName = "LoginRedirect.html";
@@ -86,8 +85,7 @@
   }
 
   @Override
-  public void doFilter(
-      final ServletRequest request, final ServletResponse response, final FilterChain chain)
+  public void doFilter(final ServletRequest request, ServletResponse response, FilterChain chain)
       throws IOException, ServletException {
     if (isSessionValid((HttpServletRequest) request)) {
       chain.doFilter(request, response);
@@ -165,7 +163,7 @@
   }
 
   @Override
-  public void init(final FilterConfig filterConfig) {}
+  public void init(FilterConfig filterConfig) {}
 
   @Override
   public void destroy() {}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthModule.java
index 638d527..f8c86ee 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthModule.java
@@ -21,7 +21,7 @@
 public class HttpAuthModule extends ServletModule {
   private final AuthConfig authConfig;
 
-  public HttpAuthModule(final AuthConfig authConfig) {
+  public HttpAuthModule(AuthConfig authConfig) {
     this.authConfig = authConfig;
   }
 
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 a8224eb..d86c85a 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
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.auth.container;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.PageLinks;
@@ -27,7 +27,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.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
@@ -39,6 +39,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.w3c.dom.Document;
@@ -78,7 +79,7 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
       throws ServletException, IOException {
     final String token = LoginUrlToken.getToken(req);
 
@@ -127,7 +128,7 @@
       try {
         log.debug("Associating external identity \"{}\" to user \"{}\"", remoteExternalId, user);
         updateRemoteExternalId(arsp, remoteExternalId);
-      } catch (AccountException | OrmException e) {
+      } catch (AccountException | OrmException | ConfigInvalidException e) {
         log.error(
             "Unable to associate external identity \""
                 + remoteExternalId
@@ -156,7 +157,7 @@
   }
 
   private void updateRemoteExternalId(AuthResult arsp, String remoteAuthToken)
-      throws AccountException, OrmException, IOException {
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
     accountManager.updateLink(
         arsp.getAccountId(),
         new AuthRequest(ExternalId.Key.create(SCHEME_EXTERNAL, remoteAuthToken)));
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
index bb3dc6a..534e50ec 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
@@ -46,7 +46,7 @@
 
   @Inject
   HttpsClientSslCertAuthFilter(
-      final DynamicItem<WebSession> webSession, final AccountManager accountManager) {
+      final DynamicItem<WebSession> webSession, AccountManager accountManager) {
     this.webSession = webSession;
     this.accountManager = accountManager;
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
index 8b14af7..e93b0b6 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertLoginServlet.java
@@ -47,8 +47,7 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     final StringBuilder rdr = new StringBuilder();
     rdr.append(urlProvider.get());
     rdr.append(LoginUrlToken.getToken(req));
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
index 4671475..316bf5d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
@@ -47,9 +47,10 @@
 import org.w3c.dom.Element;
 
 /** Handles username/password based authentication against the directory. */
-@SuppressWarnings("serial")
 @Singleton
 class LdapLoginServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private static final Logger log = LoggerFactory.getLogger(LdapLoginServlet.class);
 
   private final AccountManager accountManager;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
index 67d36e4..2019dec 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitLogoServlet.java
@@ -32,9 +32,10 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-@SuppressWarnings("serial")
 @Singleton
 class GitLogoServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final long modified;
   private final byte[] raw;
 
@@ -57,13 +58,12 @@
   }
 
   @Override
-  protected long getLastModified(final HttpServletRequest req) {
+  protected long getLastModified(HttpServletRequest req) {
     return modified;
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     if (raw != null) {
       rsp.setContentType("image/png");
       rsp.setContentLength(raw.length);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
index c5a1f18..83aed9c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebCssServlet.java
@@ -32,10 +32,13 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-@SuppressWarnings("serial")
 abstract class GitwebCssServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   @Singleton
   static class Site extends GitwebCssServlet {
+    private static final long serialVersionUID = 1L;
+
     @Inject
     Site(SitePaths paths) throws IOException {
       super(paths.site_css);
@@ -44,6 +47,8 @@
 
   @Singleton
   static class Default extends GitwebCssServlet {
+    private static final long serialVersionUID = 1L;
+
     @Inject
     Default(GitwebCgiConfig gwcc) throws IOException {
       super(gwcc.getGitwebCss());
@@ -54,7 +59,7 @@
   private final byte[] raw_css;
   private final byte[] gz_css;
 
-  GitwebCssServlet(final Path src) throws IOException {
+  GitwebCssServlet(Path src) throws IOException {
     if (src != null) {
       final Path dir = src.getParent();
       final String name = src.getFileName().toString();
@@ -76,13 +81,12 @@
   }
 
   @Override
-  protected long getLastModified(final HttpServletRequest req) {
+  protected long getLastModified(HttpServletRequest req) {
     return modified;
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     if (raw_css != null) {
       rsp.setContentType("text/css");
       rsp.setCharacterEncoding(UTF_8.name());
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
index 70f6e4c..1b26f6d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebJavaScriptServlet.java
@@ -32,9 +32,10 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-@SuppressWarnings("serial")
 @Singleton
 class GitwebJavaScriptServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final long modified;
   private final byte[] raw;
 
@@ -57,13 +58,12 @@
   }
 
   @Override
-  protected long getLastModified(final HttpServletRequest req) {
+  protected long getLastModified(HttpServletRequest req) {
     return modified;
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     if (raw != null) {
       rsp.setContentType("text/javascript");
       rsp.setContentLength(raw.length);
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 48fbd6c..ec7c477 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
@@ -33,6 +33,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AnonymousUser;
@@ -44,8 +45,10 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
@@ -81,9 +84,10 @@
 import org.slf4j.LoggerFactory;
 
 /** Invokes {@code gitweb.cgi} for the project given in {@code p}. */
-@SuppressWarnings("serial")
 @Singleton
 class GitwebServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private static final Logger log = LoggerFactory.getLogger(GitwebServlet.class);
 
   private static final String PROJECT_LIST_ACTION = "project_list";
@@ -93,7 +97,8 @@
   private final Path gitwebCgi;
   private final URI gitwebUrl;
   private final LocalDiskRepositoryManager repoManager;
-  private final ProjectControl.Factory projectControl;
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
   private final Provider<AnonymousUser> anonymousUserProvider;
   private final Provider<CurrentUser> userProvider;
   private final EnvList _env;
@@ -101,7 +106,8 @@
   @Inject
   GitwebServlet(
       GitRepositoryManager repoManager,
-      ProjectControl.Factory projectControl,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
       Provider<AnonymousUser> anonymousUserProvider,
       Provider<CurrentUser> userProvider,
       SitePaths site,
@@ -114,7 +120,8 @@
       throw new ProvisionException("Gitweb can only be used with LocalDiskRepositoryManager");
     }
     this.repoManager = (LocalDiskRepositoryManager) repoManager;
-    this.projectControl = projectControl;
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
     this.anonymousUserProvider = anonymousUserProvider;
     this.userProvider = userProvider;
     this.gitwebCgi = gitwebCgiConfig.getGitwebCgi();
@@ -365,8 +372,7 @@
   }
 
   @Override
-  protected void service(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void service(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     if (req.getQueryString() == null || req.getQueryString().isEmpty()) {
       // No query string? They want the project list, which we don't
       // currently support. Return to Gerrit's own web UI.
@@ -403,35 +409,39 @@
       name = name.substring(0, name.length() - 4);
     }
 
-    final Project.NameKey nameKey = new Project.NameKey(name);
-    final ProjectControl project;
+    Project.NameKey nameKey = new Project.NameKey(name);
     try {
-      project = projectControl.validateFor(nameKey);
-      if (!project.allRefsAreVisible() && !project.isOwner()) {
-        // Pretend the project doesn't exist
-        throw new NoSuchProjectException(nameKey);
+      if (projectCache.checkedGet(nameKey) == null) {
+        notFound(req, rsp);
+        return;
       }
-    } catch (NoSuchProjectException e) {
-      if (userProvider.get().isIdentifiedUser()) {
-        rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
-      } else {
-        // Allow anonymous users a chance to login.
-        // Avoid leaking information by not distinguishing between
-        // project not existing and no access rights.
-        rsp.sendRedirect(getLoginRedirectUrl(req));
-      }
+      permissionBackend.user(userProvider).project(nameKey).check(ProjectPermission.READ);
+    } catch (AuthException e) {
+      notFound(req, rsp);
+      return;
+    } catch (IOException | PermissionBackendException err) {
+      log.error("cannot load " + name, err);
+      rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
       return;
     }
 
     try (Repository repo = repoManager.openRepository(nameKey)) {
       CacheHeaders.setNotCacheable(rsp);
-      exec(req, rsp, project);
+      exec(req, rsp, nameKey);
     } catch (RepositoryNotFoundException e) {
       getServletContext().log("Cannot open repository", e);
       rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
     }
   }
 
+  private void notFound(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    if (userProvider.get().isIdentifiedUser()) {
+      rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+    } else {
+      rsp.sendRedirect(getLoginRedirectUrl(req));
+    }
+  }
+
   private static String getLoginRedirectUrl(HttpServletRequest req) {
     String contextPath = req.getContextPath();
     String loginUrl = contextPath + "/login/";
@@ -449,7 +459,7 @@
 
   private static Map<String, String> getParameters(HttpServletRequest req) {
     final Map<String, String> params = new HashMap<>();
-    for (final String pair : req.getQueryString().split("[&;]")) {
+    for (String pair : req.getQueryString().split("[&;]")) {
       final int eq = pair.indexOf('=');
       if (0 < eq) {
         String name = pair.substring(0, eq);
@@ -463,8 +473,7 @@
     return params;
   }
 
-  private void exec(
-      final HttpServletRequest req, final HttpServletResponse rsp, final ProjectControl project)
+  private void exec(HttpServletRequest req, HttpServletResponse rsp, Project.NameKey project)
       throws IOException {
     final Process proc =
         Runtime.getRuntime()
@@ -513,7 +522,7 @@
     }
   }
 
-  private String[] makeEnv(final HttpServletRequest req, final ProjectControl project) {
+  private String[] makeEnv(HttpServletRequest req, Project.NameKey nameKey) {
     final EnvList env = new EnvList(_env);
     final int contentLength = Math.max(0, req.getContentLength());
 
@@ -552,20 +561,21 @@
     }
 
     env.set("GERRIT_CONTEXT_PATH", req.getContextPath() + "/");
-    env.set("GERRIT_PROJECT_NAME", project.getProject().getName());
+    env.set("GERRIT_PROJECT_NAME", nameKey.get());
 
-    env.set(
-        "GITWEB_PROJECTROOT",
-        repoManager.getBasePath(project.getProject().getNameKey()).toAbsolutePath().toString());
+    env.set("GITWEB_PROJECTROOT", repoManager.getBasePath(nameKey).toAbsolutePath().toString());
 
-    if (project.forUser(anonymousUserProvider.get()).isVisible()) {
+    if (permissionBackend
+        .user(anonymousUserProvider)
+        .project(nameKey)
+        .testOrFalse(ProjectPermission.READ)) {
       env.set("GERRIT_ANONYMOUS_READ", "1");
     }
 
     String remoteUser = null;
-    if (project.getUser().isIdentifiedUser()) {
-      final IdentifiedUser u = project.getUser().asIdentifiedUser();
-      final String user = u.getUserName();
+    if (userProvider.get().isIdentifiedUser()) {
+      IdentifiedUser u = userProvider.get().asIdentifiedUser();
+      String user = u.getUserName();
       env.set("GERRIT_USER_NAME", user);
       if (user != null && !user.isEmpty()) {
         remoteUser = user;
@@ -613,8 +623,7 @@
     return env.getEnvArray();
   }
 
-  private void copyContentToCGI(final HttpServletRequest req, final OutputStream dst)
-      throws IOException {
+  private void copyContentToCGI(HttpServletRequest req, OutputStream dst) throws IOException {
     final int contentLength = req.getContentLength();
     final InputStream src = req.getInputStream();
     new Thread(
@@ -643,7 +652,7 @@
         .start();
   }
 
-  private void copyStderrToLog(final InputStream in) {
+  private void copyStderrToLog(InputStream in) {
     new Thread(
             () -> {
               try (BufferedReader br =
@@ -669,7 +678,7 @@
     return req.getHeaderNames();
   }
 
-  private void readCgiHeaders(HttpServletResponse res, final InputStream in) throws IOException {
+  private void readCgiHeaders(HttpServletResponse res, InputStream in) throws IOException {
     String line;
     while (!(line = readLine(in)).isEmpty()) {
       if (line.startsWith("HTTP")) {
@@ -700,7 +709,7 @@
     }
   }
 
-  private String readLine(final InputStream in) throws IOException {
+  private String readLine(InputStream in) throws IOException {
     final StringBuilder buf = new StringBuilder();
     int b;
     while ((b = in.read()) != -1 && b != '\n') {
@@ -717,12 +726,12 @@
       envMap = new HashMap<>();
     }
 
-    EnvList(final EnvList l) {
+    EnvList(EnvList l) {
       envMap = new HashMap<>(l.envMap);
     }
 
     /** Set a name/value pair, null values will be treated as an empty String */
-    public void set(final String name, String value) {
+    public void set(String name, String value) {
       if (value == null) {
         value = "";
       }
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 279903c..937b24a 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
@@ -15,9 +15,11 @@
 package com.google.gerrit.httpd.plugins;
 
 import com.google.gerrit.extensions.api.lfs.LfsDefinitions;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.ResourceWeigher;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -63,5 +65,7 @@
                 .weigher(ResourceWeigher.class);
           }
         });
+
+    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
   }
 }
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 b64b3b3..9b55042 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
@@ -71,6 +71,7 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentMap;
@@ -678,7 +679,11 @@
     Path path = plugin.getSrcFile();
     if (req.getRequestURI().endsWith(getJsPluginPath(plugin)) && Files.exists(path)) {
       res.setHeader("Content-Length", Long.toString(Files.size(path)));
-      res.setContentType("application/javascript");
+      if (path.toString().toLowerCase(Locale.US).endsWith(".html")) {
+        res.setContentType("text/html");
+      } else {
+        res.setContentType("application/javascript");
+      }
       writeToResponse(res, Files.newInputStream(path));
     } else {
       resourceCache.put(key, Resource.NOT_FOUND);
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 7e298aa..d28e582 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
@@ -24,7 +24,10 @@
 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;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -35,6 +38,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Exports a single version of a patch as a normal file download.
@@ -44,32 +48,35 @@
  * this site, and will execute it with the site's own protection domain. This opens a massive
  * security hole so we package the content into a zip file.
  */
-@SuppressWarnings("serial")
 @Singleton
 public class CatServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final Provider<ReviewDb> requestDb;
   private final Provider<CurrentUser> userProvider;
-  private final ChangeControl.GenericFactory changeControl;
   private final ChangeEditUtil changeEditUtil;
   private final PatchSetUtil psUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   CatServlet(
       Provider<ReviewDb> sf,
-      ChangeControl.GenericFactory ccf,
       Provider<CurrentUser> usrprv,
       ChangeEditUtil ceu,
-      PatchSetUtil psu) {
+      PatchSetUtil psu,
+      ChangeNotes.Factory cnf,
+      PermissionBackend pb) {
     requestDb = sf;
-    changeControl = ccf;
     userProvider = usrprv;
     changeEditUtil = ceu;
     psUtil = psu;
+    changeNotesFactory = cnf;
+    permissionBackend = pb;
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     String keyStr = req.getPathInfo();
 
     // We shouldn't have to do this extra decode pass, but somehow we
@@ -119,34 +126,33 @@
     final Change.Id changeId = patchKey.getParentKey().getParentKey();
     String revision;
     try {
-      final ReviewDb db = requestDb.get();
-      final ChangeControl control = changeControl.validateFor(db, changeId, userProvider.get());
+      ChangeNotes notes = changeNotesFactory.createChecked(changeId);
+      permissionBackend
+          .user(userProvider)
+          .change(notes)
+          .database(requestDb)
+          .check(ChangePermission.READ);
       if (patchKey.getParentKey().get() == 0) {
         // change edit
-        try {
-          Optional<ChangeEdit> edit = changeEditUtil.byChange(control.getChange());
-          if (edit.isPresent()) {
-            revision = edit.get().getRevision().get();
-          } else {
-            rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-          }
-        } catch (AuthException e) {
+        Optional<ChangeEdit> edit = changeEditUtil.byChange(notes);
+        if (edit.isPresent()) {
+          revision = ObjectId.toString(edit.get().getEditCommit());
+        } else {
           rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
           return;
         }
       } else {
-        PatchSet patchSet = psUtil.get(db, control.getNotes(), patchKey.getParentKey());
+        PatchSet patchSet = psUtil.get(requestDb.get(), notes, patchKey.getParentKey());
         if (patchSet == null) {
           rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
           return;
         }
         revision = patchSet.getRevision().get();
       }
-    } catch (NoSuchChangeException e) {
+    } catch (NoSuchChangeException | AuthException e) {
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       getServletContext().log("Cannot query database", e);
       rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
       return;
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 0e1f6e2..9818a34 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
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.JsonServlet;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
@@ -66,9 +67,10 @@
 import org.w3c.dom.Node;
 
 /** Sends the Gerrit host page to clients. */
-@SuppressWarnings("serial")
 @Singleton
 public class HostPageServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private static final Logger log = LoggerFactory.getLogger(HostPageServlet.class);
 
   private static final String HPD_ID = "gerrit_hostpagedata";
@@ -91,7 +93,6 @@
   private volatile Page page;
 
   @Inject
-  @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
   HostPageServlet(
       Provider<CurrentUser> cu,
       SitePaths sp,
@@ -133,7 +134,7 @@
     String src = "gerrit_ui/gerrit_ui.nocache.js";
     try (InputStream in = servletContext.getResourceAsStream("/" + src)) {
       if (in != null) {
-        Hasher md = Hashing.md5().newHasher();
+        Hasher md = Hashing.murmur3_128().newHasher();
         byte[] buf = new byte[1024];
         int n;
         while ((n = in.read(buf)) > 0) {
@@ -221,7 +222,7 @@
   private DiffPreferencesInfo getDiffPreferences(IdentifiedUser user) {
     try {
       return getDiff.apply(new AccountResource(user));
-    } catch (AuthException | ConfigInvalidException | IOException e) {
+    } catch (AuthException | ConfigInvalidException | IOException | PermissionBackendException e) {
       log.warn("Cannot query account diff preferences", e);
     }
     return DiffPreferencesInfo.defaults();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java
index 706e177..db0212e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -35,7 +35,7 @@
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
-  private final byte[] indexSource;
+  protected final byte[] indexSource;
 
   IndexServlet(String canonicalURL, @Nullable String cdnPath) throws URISyntaxException {
     String resourcePath = "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
index 8ccf221..c34b3cb 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/LegacyGerritServlet.java
@@ -34,9 +34,10 @@
  * as it would lose any history token that appears in the URL. Instead we send an HTML page which
  * instructs the browser to replace the URL, but preserve the history token.
  */
-@SuppressWarnings("serial")
 @Singleton
 public class LegacyGerritServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final byte[] raw;
   private final byte[] compressed;
 
@@ -53,8 +54,7 @@
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     final byte[] tosend;
     if (RPCServletUtils.acceptsGzipEncoding(req)) {
       rsp.setHeader("Content-Encoding", "gzip");
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 3ca1878..3ec6bdb 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
@@ -288,7 +288,7 @@
         || name.contains("//"); // windows UNC path can be "//..."
   }
 
-  private Callable<Resource> newLoader(final Path p) {
+  private Callable<Resource> newLoader(Path p) {
     return () -> {
       try {
         return new Resource(
@@ -307,12 +307,11 @@
     final String etag;
     final byte[] raw;
 
-    @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
     Resource(FileTime lastModified, String contentType, byte[] raw) {
       this.lastModified = checkNotNull(lastModified, "lastModified");
       this.contentType = checkNotNull(contentType, "contentType");
       this.raw = checkNotNull(raw, "raw");
-      this.etag = Hashing.md5().hashBytes(raw).toString();
+      this.etag = Hashing.murmur3_128().hashBytes(raw).toString();
     }
 
     boolean isStale(Path p, ResourceServlet rs) throws IOException {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
index b20f990..d68b009 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
@@ -46,19 +46,19 @@
  *  Port 8010
  * }</pre>
  */
-@SuppressWarnings("serial")
 @Singleton
 public class SshInfoServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final SshInfo sshd;
 
   @Inject
-  SshInfoServlet(final SshInfo daemon) {
+  SshInfoServlet(SshInfo daemon) {
     sshd = daemon;
   }
 
   @Override
-  protected void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     final List<HostKey> hostKeys = sshd.getHostKeys();
     final String out;
     if (!hostKeys.isEmpty()) {
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 a1dbbb8..35e36f0 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
@@ -120,6 +120,8 @@
 
   @Override
   protected void configureServlets() {
+    serveRegex("^/Documentation$").with(named(DOC_SERVLET));
+    serveRegex("^/Documentation/$").with(named(DOC_SERVLET));
     serveRegex("^/Documentation/(.+)$").with(named(DOC_SERVLET));
     serve("/static/*").with(SiteStaticDirectoryServlet.class);
     install(
@@ -240,8 +242,8 @@
     public void configureServlets() {
       for (String p : POLYGERRIT_INDEX_PATHS) {
         // Skip XsrfCookieFilter for /, since that is already done in the GWT UI
-        // path (UrlModule).
-        if (!p.equals("/")) {
+        // path (UrlModule) if it is enabled.
+        if (!(p.equals("/") && options.enableGwtUi())) {
           filter(p).through(XsrfCookieFilter.class);
         }
       }
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 ced3121..bfaf0c7 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
@@ -14,19 +14,29 @@
 
 package com.google.gerrit.httpd.restapi;
 
+import static com.google.gerrit.httpd.restapi.RestApiServlet.ALLOWED_CORS_METHODS;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_CONTENT_TYPE;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_METHOD;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.replyBinaryResult;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.replyError;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
 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.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
@@ -34,6 +44,7 @@
 import com.google.gson.JsonPrimitive;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.HashSet;
@@ -44,21 +55,119 @@
 import javax.servlet.http.HttpServletResponse;
 import org.kohsuke.args4j.CmdLineException;
 
-class ParameterParser {
+public class ParameterParser {
   private static final ImmutableSet<String> RESERVED_KEYS =
       ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
 
+  @AutoValue
+  public abstract static class QueryParams {
+    static final String I = QueryParams.class.getName();
+
+    static QueryParams create(
+        @Nullable String accessToken,
+        @Nullable String xdMethod,
+        @Nullable String xdContentType,
+        ImmutableListMultimap<String, String> config,
+        ImmutableListMultimap<String, String> params) {
+      return new AutoValue_ParameterParser_QueryParams(
+          accessToken, xdMethod, xdContentType, config, params);
+    }
+
+    @Nullable
+    public abstract String accessToken();
+
+    @Nullable
+    abstract String xdMethod();
+
+    @Nullable
+    abstract String xdContentType();
+
+    abstract ImmutableListMultimap<String, String> config();
+
+    abstract ImmutableListMultimap<String, String> params();
+
+    boolean hasXdOverride() {
+      return xdMethod() != null || xdContentType() != null;
+    }
+  }
+
+  public static QueryParams getQueryParams(HttpServletRequest req) throws BadRequestException {
+    QueryParams qp = (QueryParams) req.getAttribute(QueryParams.I);
+    if (qp != null) {
+      return qp;
+    }
+
+    String accessToken = null;
+    String xdMethod = null;
+    String xdContentType = null;
+    ListMultimap<String, String> config = MultimapBuilder.hashKeys(4).arrayListValues().build();
+    ListMultimap<String, String> params = MultimapBuilder.hashKeys().arrayListValues().build();
+
+    String queryString = req.getQueryString();
+    if (!Strings.isNullOrEmpty(queryString)) {
+      for (String kvPair : Splitter.on('&').split(queryString)) {
+        Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
+        String key = Url.decode(i.next());
+        String val = i.hasNext() ? Url.decode(i.next()) : "";
+
+        if (XD_AUTHORIZATION.equals(key)) {
+          if (accessToken != null) {
+            throw new BadRequestException("duplicate " + XD_AUTHORIZATION);
+          }
+          accessToken = val;
+        } else if (XD_METHOD.equals(key)) {
+          if (xdMethod != null) {
+            throw new BadRequestException("duplicate " + XD_METHOD);
+          } else if (!ALLOWED_CORS_METHODS.contains(val)) {
+            throw new BadRequestException("invalid " + XD_METHOD);
+          }
+          xdMethod = val;
+        } else if (XD_CONTENT_TYPE.equals(key)) {
+          if (xdContentType != null) {
+            throw new BadRequestException("duplicate " + XD_CONTENT_TYPE);
+          }
+          xdContentType = val;
+        } else if (RESERVED_KEYS.contains(key)) {
+          config.put(key, val);
+        } else {
+          params.put(key, val);
+        }
+      }
+    }
+
+    qp =
+        QueryParams.create(
+            accessToken,
+            xdMethod,
+            xdContentType,
+            ImmutableListMultimap.copyOf(config),
+            ImmutableListMultimap.copyOf(params));
+    req.setAttribute(QueryParams.I, qp);
+    return qp;
+  }
+
   private final CmdLineParser.Factory parserFactory;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
-  ParameterParser(CmdLineParser.Factory pf) {
+  ParameterParser(
+      CmdLineParser.Factory pf,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.parserFactory = pf;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   <T> boolean parse(
       T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
       throws IOException {
     CmdLineParser clp = parserFactory.create(param);
+    DynamicOptions pluginOptions = new DynamicOptions(param, injector, dynamicBeans);
+    pluginOptions.parseDynamicBeans(clp);
+    pluginOptions.setDynamicBeans();
+    pluginOptions.onBeanParseStart();
     try {
       clp.parseOptionMap(in);
     } catch (CmdLineException | NumberFormatException e) {
@@ -79,28 +188,11 @@
       replyBinaryResult(req, res, BinaryResult.create(msg.toString()).setContentType("text/plain"));
       return false;
     }
+    pluginOptions.onBeanParseEnd();
 
     return true;
   }
 
-  static void splitQueryString(
-      String queryString,
-      ListMultimap<String, String> config,
-      ListMultimap<String, String> params) {
-    if (!Strings.isNullOrEmpty(queryString)) {
-      for (String kvPair : Splitter.on('&').split(queryString)) {
-        Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
-        String key = Url.decode(i.next());
-        String val = i.hasNext() ? Url.decode(i.next()) : "";
-        if (RESERVED_KEYS.contains(key)) {
-          config.put(key, val);
-        } else {
-          params.put(key, val);
-        }
-      }
-    }
-  }
-
   private static Set<String> query(HttpServletRequest req) {
     Set<String> params = new HashSet<>();
     if (!Strings.isNullOrEmpty(req.getQueryString())) {
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 bfa91d6..f73e27d 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
@@ -12,15 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+// WARNING: NoteDbUpdateManager cares about the package name RestApiServlet lives in.
 package com.google.gerrit.httpd.restapi;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
 import static java.math.RoundingMode.CEILING;
@@ -51,7 +56,6 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.io.BaseEncoding;
 import com.google.common.io.CountingOutputStream;
 import com.google.common.math.IntMath;
@@ -90,13 +94,16 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.account.CapabilityUtils;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.util.http.RequestUtil;
 import com.google.gson.ExclusionStrategy;
 import com.google.gson.FieldAttributes;
@@ -113,12 +120,14 @@
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
 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;
@@ -133,17 +142,20 @@
 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.TreeMap;
 import java.util.concurrent.TimeUnit;
 import java.util.regex.Pattern;
-import java.util.stream.StreamSupport;
+import java.util.stream.Stream;
 import java.util.zip.GZIPOutputStream;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.http.server.ServletUtils;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.TemporaryBuffer;
 import org.eclipse.jgit.util.TemporaryBuffer.Heap;
@@ -163,8 +175,17 @@
   // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available
   private static final int SC_UNPROCESSABLE_ENTITY = 422;
   private static final String X_REQUESTED_WITH = "X-Requested-With";
+  private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
+  static final ImmutableSet<String> ALLOWED_CORS_METHODS =
+      ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
   private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
-      ImmutableSet.of(X_REQUESTED_WITH);
+      Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
+          .map(s -> s.toLowerCase(Locale.US))
+          .collect(ImmutableSet.toImmutableSet());
+
+  public static final String XD_AUTHORIZATION = "access_token";
+  public static final String XD_CONTENT_TYPE = "$ct";
+  public static final String XD_METHOD = "$m";
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
 
@@ -186,6 +207,7 @@
     final Provider<CurrentUser> currentUser;
     final DynamicItem<WebSession> webSession;
     final Provider<ParameterParser> paramParser;
+    final PermissionBackend permissionBackend;
     final AuditService auditService;
     final RestApiMetrics metrics;
     final Pattern allowOrigin;
@@ -195,12 +217,14 @@
         Provider<CurrentUser> currentUser,
         DynamicItem<WebSession> webSession,
         Provider<ParameterParser> paramParser,
+        PermissionBackend permissionBackend,
         AuditService auditService,
         RestApiMetrics metrics,
         @GerritServerConfig Config cfg) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
+      this.permissionBackend = permissionBackend;
       this.auditService = auditService;
       this.metrics = metrics;
       allowOrigin = makeAllowOrigin(cfg);
@@ -243,8 +267,7 @@
     int status = SC_OK;
     long responseBytes = -1;
     Object result = null;
-    ListMultimap<String, String> params = MultimapBuilder.hashKeys().arrayListValues().build();
-    ListMultimap<String, String> config = MultimapBuilder.hashKeys().arrayListValues().build();
+    QueryParams qp = null;
     Object inputRequestBody = null;
     RestResource rsrc = TopLevelResource.INSTANCE;
     ViewData viewData = null;
@@ -254,20 +277,26 @@
         doCorsPreflight(req, res);
         return;
       }
-      checkCors(req, res);
-      checkUserSession(req);
 
-      ParameterParser.splitQueryString(req.getQueryString(), config, params);
+      qp = ParameterParser.getQueryParams(req);
+      checkCors(req, res, qp.hasXdOverride());
+      if (qp.hasXdOverride()) {
+        req = applyXdOverrides(req, qp);
+      }
+      checkUserSession(req);
 
       List<IdString> path = splitPath(req);
       RestCollection<RestResource, RestResource> rc = members.get();
-      CapabilityUtils.checkRequiresCapability(globals.currentUser, null, rc.getClass());
+      globals
+          .permissionBackend
+          .user(globals.currentUser)
+          .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
 
       viewData = new ViewData(null, null);
 
       if (path.isEmpty()) {
         if (rc instanceof NeedsParams) {
-          ((NeedsParams) rc).setParams(params);
+          ((NeedsParams) rc).setParams(qp.params());
         }
 
         if (isRead(req)) {
@@ -360,7 +389,7 @@
         return;
       }
 
-      if (!globals.paramParser.get().parse(viewData.view, params, req, res)) {
+      if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
         return;
       }
 
@@ -371,8 +400,14 @@
         RestModifyView<RestResource, Object> m =
             (RestModifyView<RestResource, Object>) viewData.view;
 
-        inputRequestBody = parseRequest(req, inputType(m));
+        Type type = inputType(m);
+        inputRequestBody = parseRequest(req, type);
         result = m.apply(rsrc, inputRequestBody);
+        if (inputRequestBody instanceof RawInput) {
+          try (InputStream is = req.getInputStream()) {
+            ServletUtils.consumeRequestBody(is);
+          }
+        }
       } else {
         throw new ResourceNotFoundException();
       }
@@ -401,7 +436,7 @@
         if (result instanceof BinaryResult) {
           responseBytes = replyBinaryResult(req, res, (BinaryResult) result);
         } else {
-          responseBytes = replyJson(req, res, config, result);
+          responseBytes = replyJson(req, res, qp.config(), result);
         }
       }
     } catch (MalformedJsonException e) {
@@ -476,7 +511,7 @@
               globals.currentUser.get(),
               req,
               auditStartTs,
-              params,
+              qp != null ? qp.params() : ImmutableListMultimap.of(),
               inputRequestBody,
               status,
               result,
@@ -485,11 +520,54 @@
     }
   }
 
-  private void checkCors(HttpServletRequest req, HttpServletResponse res) {
+  private static HttpServletRequest applyXdOverrides(HttpServletRequest req, QueryParams qp)
+      throws BadRequestException {
+    if (!"POST".equals(req.getMethod())) {
+      throw new BadRequestException("POST required");
+    }
+
+    String method = qp.xdMethod();
+    String contentType = qp.xdContentType();
+    if (method.equals("POST") || method.equals("PUT")) {
+      if (!"text/plain".equals(req.getContentType())) {
+        throw new BadRequestException("invalid " + CONTENT_TYPE);
+      } else if (Strings.isNullOrEmpty(contentType)) {
+        throw new BadRequestException(XD_CONTENT_TYPE + " required");
+      }
+    }
+
+    return new HttpServletRequestWrapper(req) {
+      @Override
+      public String getMethod() {
+        return method;
+      }
+
+      @Override
+      public String getContentType() {
+        return contentType;
+      }
+    };
+  }
+
+  private void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
+      throws BadRequestException {
     String origin = req.getHeader(ORIGIN);
-    if (isRead(req) && !Strings.isNullOrEmpty(origin) && isOriginAllowed(origin)) {
+    if (isXd) {
+      // Cross-domain, non-preflighted requests must come from an approved origin.
+      if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+        throw new BadRequestException("origin not allowed");
+      }
       res.addHeader(VARY, ORIGIN);
-      setCorsHeaders(res, origin);
+      res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+      res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    } else if (!Strings.isNullOrEmpty(origin)) {
+      // All other requests must be processed, but conditionally set CORS headers.
+      if (globals.allowOrigin != null) {
+        res.addHeader(VARY, ORIGIN);
+      }
+      if (isOriginAllowed(origin)) {
+        setCorsHeaders(res, origin);
+      }
     }
   }
 
@@ -502,8 +580,10 @@
   private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
       throws BadRequestException {
     CacheHeaders.setNotCacheable(res);
-    res.setHeader(
-        VARY, Joiner.on(", ").join(ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD)));
+    setHeaderList(
+        res,
+        VARY,
+        ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
 
     String origin = req.getHeader(ORIGIN);
     if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
@@ -511,20 +591,16 @@
     }
 
     String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
-    if (!"GET".equals(method) && !"HEAD".equals(method)) {
+    if (!ALLOWED_CORS_METHODS.contains(method)) {
       throw new BadRequestException(method + " not allowed in CORS");
     }
 
     String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
     if (headers != null) {
-      res.addHeader(VARY, ACCESS_CONTROL_REQUEST_HEADERS);
-      String badHeader =
-          StreamSupport.stream(Splitter.on(',').trimResults().split(headers).spliterator(), false)
-              .filter(h -> !ALLOWED_CORS_REQUEST_HEADERS.contains(h))
-              .findFirst()
-              .orElse(null);
-      if (badHeader != null) {
-        throw new BadRequestException(badHeader + " not allowed in CORS");
+      for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
+        if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
+          throw new BadRequestException(reqHdr + " not allowed in CORS");
+        }
       }
     }
 
@@ -534,11 +610,19 @@
     res.setContentLength(0);
   }
 
-  private void setCorsHeaders(HttpServletResponse res, String origin) {
+  private static void setCorsHeaders(HttpServletResponse res, String origin) {
     res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
     res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, OPTIONS");
-    res.setHeader(ACCESS_CONTROL_ALLOW_HEADERS, Joiner.on(", ").join(ALLOWED_CORS_REQUEST_HEADERS));
+    res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
+    setHeaderList(
+        res,
+        ACCESS_CONTROL_ALLOW_METHODS,
+        Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
+    setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
+  }
+
+  private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
+    res.setHeader(name, Joiner.on(", ").join(values));
   }
 
   private boolean isOriginAllowed(String origin) {
@@ -626,64 +710,53 @@
   }
 
   private static Type inputType(RestModifyView<RestResource, Object> m) {
-    Type inputType = extractInputType(m.getClass());
-    if (inputType == null) {
-      throw new IllegalStateException(
-          String.format(
-              "View %s does not correctly implement %s",
-              m.getClass(), RestModifyView.class.getSimpleName()));
-    }
-    return inputType;
-  }
+    // MyModifyView implements RestModifyView<SomeResource, MyInput>
+    TypeLiteral<?> typeLiteral = TypeLiteral.get(m.getClass());
 
-  @SuppressWarnings("rawtypes")
-  private static Type extractInputType(Class clazz) {
-    for (Type t : clazz.getGenericInterfaces()) {
-      if (t instanceof ParameterizedType
-          && ((ParameterizedType) t).getRawType() == RestModifyView.class) {
-        return ((ParameterizedType) t).getActualTypeArguments()[1];
-      }
-    }
+    // RestModifyView<SomeResource, MyInput>
+    // This is smart enough to resolve even when there are intervening subclasses, even if they have
+    // reordered type arguments.
+    TypeLiteral<?> supertypeLiteral = typeLiteral.getSupertype(RestModifyView.class);
 
-    if (clazz.getSuperclass() != null) {
-      Type i = extractInputType(clazz.getSuperclass());
-      if (i != null) {
-        return i;
-      }
-    }
-
-    for (Class t : clazz.getInterfaces()) {
-      Type i = extractInputType(t);
-      if (i != null) {
-        return i;
-      }
-    }
-
-    return null;
+    Type supertype = supertypeLiteral.getType();
+    checkState(
+        supertype instanceof ParameterizedType,
+        "supertype of %s is not parameterized: %s",
+        typeLiteral,
+        supertypeLiteral);
+    return ((ParameterizedType) supertype).getActualTypeArguments()[1];
   }
 
   private Object parseRequest(HttpServletRequest req, Type type)
       throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
           NoSuchMethodException, IllegalAccessException, InstantiationException,
           InvocationTargetException, MethodNotAllowedException {
+    // HTTP/1.1 requires consuming the request body before writing non-error response (less than
+    // 400). Consume the request body for all but raw input request types here.
     if (isType(JSON_TYPE, req.getContentType())) {
       try (BufferedReader br = req.getReader();
           JsonReader json = new JsonReader(br)) {
-        json.setLenient(true);
-
-        JsonToken first;
         try {
-          first = json.peek();
-        } catch (EOFException e) {
-          throw new BadRequestException("Expected JSON object");
+          json.setLenient(true);
+
+          JsonToken first;
+          try {
+            first = json.peek();
+          } catch (EOFException e) {
+            throw new BadRequestException("Expected JSON object");
+          }
+          if (first == JsonToken.STRING) {
+            return parseString(json.nextString(), type);
+          }
+          return OutputFormat.JSON.newGson().fromJson(json, type);
+        } finally {
+          // Reader.close won't consume the rest of the input. Explicitly consume the request body.
+          br.skip(Long.MAX_VALUE);
         }
-        if (first == JsonToken.STRING) {
-          return parseString(json.nextString(), type);
-        }
-        return OutputFormat.JSON.newGson().fromJson(json, type);
       }
-    } else if (("PUT".equals(req.getMethod()) || "POST".equals(req.getMethod()))
-        && acceptsRawInput(type)) {
+    }
+    String method = req.getMethod();
+    if (("PUT".equals(method) || "POST".equals(method)) && acceptsRawInput(type)) {
       return parseRawInput(req, type);
     } else if ("DELETE".equals(req.getMethod()) && hasNoBody(req)) {
       return null;
@@ -724,7 +797,7 @@
     return false;
   }
 
-  private Object parseRawInput(final HttpServletRequest req, Type type)
+  private Object parseRawInput(HttpServletRequest req, Type type)
       throws SecurityException, NoSuchMethodException, IllegalArgumentException,
           InstantiationException, IllegalAccessException, InvocationTargetException,
           MethodNotAllowedException {
@@ -899,7 +972,7 @@
     }
   }
 
-  private static BinaryResult stackJsonString(HttpServletResponse res, final BinaryResult src)
+  private static BinaryResult stackJsonString(HttpServletResponse res, BinaryResult src)
       throws IOException {
     TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE);
     buf.write(JSON_MAGIC);
@@ -915,7 +988,7 @@
     return asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8);
   }
 
-  private static BinaryResult stackBase64(HttpServletResponse res, final BinaryResult src)
+  private static BinaryResult stackBase64(HttpServletResponse res, BinaryResult src)
       throws IOException {
     BinaryResult b64;
     long len = src.getContentLength();
@@ -946,7 +1019,7 @@
     return b64.setContentType("text/plain").setCharacterEncoding(ISO_8859_1);
   }
 
-  private static BinaryResult stackGzip(HttpServletResponse res, final BinaryResult src)
+  private static BinaryResult stackGzip(HttpServletResponse res, BinaryResult src)
       throws IOException {
     BinaryResult gz;
     long len = src.getContentLength();
@@ -1069,7 +1142,6 @@
     CurrentUser user = globals.currentUser.get();
     if (isRead(req)) {
       user.setAccessPath(AccessPath.REST_API);
-      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
     } else if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
     } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
@@ -1077,15 +1149,29 @@
           "Invalid authentication method. In order to authenticate, "
               + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
     }
+    if (user.isIdentifiedUser()) {
+      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
+    }
   }
 
   private static boolean isRead(HttpServletRequest req) {
     return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
   }
 
-  private void checkRequiresCapability(ViewData viewData) throws AuthException {
-    CapabilityUtils.checkRequiresCapability(
-        globals.currentUser, viewData.pluginName, viewData.view.getClass());
+  private void checkRequiresCapability(ViewData d)
+      throws AuthException, PermissionBackendException {
+    try {
+      globals
+          .permissionBackend
+          .user(globals.currentUser)
+          .check(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (AuthException e) {
+      // Skiping
+      globals
+          .permissionBackend
+          .user(globals.currentUser)
+          .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
+    }
   }
 
   private static long handleException(
@@ -1196,7 +1282,7 @@
   }
 
   @SuppressWarnings("resource")
-  private static BinaryResult asBinaryResult(final TemporaryBuffer.Heap buf) {
+  private static BinaryResult asBinaryResult(TemporaryBuffer.Heap buf) {
     return new BinaryResult() {
       @Override
       public void writeTo(OutputStream os) throws IOException {
@@ -1209,8 +1295,9 @@
     return new TemporaryBuffer.Heap(est, max);
   }
 
-  @SuppressWarnings("serial")
   private static class AmbiguousViewException extends Exception {
+    private static final long serialVersionUID = 1L;
+
     AmbiguousViewException(String message) {
       super(message);
     }
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 e561c9b..9e0e8f6 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
@@ -34,7 +34,7 @@
   private final Provider<? extends CurrentUser> currentUser;
 
   protected BaseServiceImplementation(
-      final Provider<ReviewDb> schema, final Provider<? extends CurrentUser> currentUser) {
+      final Provider<ReviewDb> schema, Provider<? extends CurrentUser> currentUser) {
     this.schema = schema;
     this.currentUser = currentUser;
   }
@@ -63,7 +63,7 @@
    * @param callback the callback that will receive the result.
    * @param action the action logic to perform.
    */
-  protected <T> void run(final AsyncCallback<T> callback, final Action<T> action) {
+  protected <T> void run(AsyncCallback<T> callback, Action<T> action) {
     try {
       final T r = action.run(schema.get());
       if (r != null) {
@@ -100,7 +100,7 @@
     }
   }
 
-  private static <T> void handleOrmException(final AsyncCallback<T> callback, Exception e) {
+  private static <T> void handleOrmException(AsyncCallback<T> callback, Exception e) {
     if (e.getCause() instanceof Failure) {
       callback.onFailure(e.getCause().getCause());
     } else if (e.getCause() instanceof NoSuchEntityException) {
@@ -114,7 +114,7 @@
   public static class Failure extends Exception {
     private static final long serialVersionUID = 1L;
 
-    public Failure(final Throwable why) {
+    public Failure(Throwable why) {
       super(why);
     }
   }
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 cce87a8..d3d49cb 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
@@ -42,8 +42,9 @@
 import org.slf4j.LoggerFactory;
 
 /** Base JSON servlet to ensure the current user is not forged. */
-@SuppressWarnings("serial")
 final class GerritJsonServlet extends JsonServlet<GerritJsonServlet.GerritCall> {
+  private static final long serialVersionUID = 1L;
+
   private static final Logger log = LoggerFactory.getLogger(GerritJsonServlet.class);
   private static final ThreadLocal<GerritCall> currentCall = new ThreadLocal<>();
   private static final ThreadLocal<MethodHandle> currentMethod = new ThreadLocal<>();
@@ -52,16 +53,14 @@
   private final AuditService audit;
 
   @Inject
-  GerritJsonServlet(
-      final DynamicItem<WebSession> w, final RemoteJsonService s, final AuditService a) {
+  GerritJsonServlet(final DynamicItem<WebSession> w, RemoteJsonService s, AuditService a) {
     session = w;
     service = s;
     audit = a;
   }
 
   @Override
-  protected GerritCall createActiveCall(
-      final HttpServletRequest req, final HttpServletResponse rsp) {
+  protected GerritCall createActiveCall(final HttpServletRequest req, HttpServletResponse rsp) {
     final GerritCall call = new GerritCall(session.get(), req, new AuditedHttpServletResponse(rsp));
     currentCall.set(call);
     return call;
@@ -82,7 +81,7 @@
   }
 
   @Override
-  protected void preInvoke(final GerritCall call) {
+  protected void preInvoke(GerritCall call) {
     super.preInvoke(call);
 
     if (call.isComplete()) {
@@ -106,8 +105,7 @@
   }
 
   @Override
-  protected void service(final HttpServletRequest req, final HttpServletResponse resp)
-      throws IOException {
+  protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
     try {
       super.service(req, resp);
     } finally {
@@ -163,7 +161,7 @@
     return args;
   }
 
-  private String extractWhat(final Audit note, final GerritCall call) {
+  private String extractWhat(Audit note, GerritCall call) {
     Class<?> methodClass = call.getMethodClass();
     String methodClassName = methodClass != null ? methodClass.getName() : "<UNKNOWN_CLASS>";
     methodClassName = methodClassName.substring(methodClassName.lastIndexOf(".") + 1);
@@ -233,7 +231,7 @@
       return null;
     }
 
-    GerritCall(final WebSession session, final HttpServletRequest i, final HttpServletResponse o) {
+    GerritCall(WebSession session, HttpServletRequest i, HttpServletResponse o) {
       super(i, o);
       this.session = session;
       this.when = TimeUtil.nowMs();
@@ -248,7 +246,7 @@
     }
 
     @Override
-    public void onFailure(final Throwable error) {
+    public void onFailure(Throwable error) {
       if (error instanceof IllegalArgumentException || error instanceof IllegalStateException) {
         super.onFailure(error);
       } else if (error instanceof OrmException || error instanceof RuntimeException) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
index 9fd9269..b167167 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/GerritJsonServletProvider.java
@@ -27,7 +27,7 @@
   private final Class<? extends RemoteJsonService> serviceClass;
 
   @Inject
-  GerritJsonServletProvider(final Class<? extends RemoteJsonService> c) {
+  GerritJsonServletProvider(Class<? extends RemoteJsonService> c) {
     serviceClass = c;
   }
 
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 a9d654c..b932169 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
@@ -44,7 +44,7 @@
  *     successfully.
  */
 public abstract class Handler<T> implements Callable<T> {
-  public static <T> Handler<T> wrap(final Callable<T> r) {
+  public static <T> Handler<T> wrap(Callable<T> r) {
     return new Handler<T>() {
       @Override
       public T call() throws Exception {
@@ -58,7 +58,7 @@
    *
    * @param callback callback to receive the result of {@link #call()}.
    */
-  public final void to(final AsyncCallback<T> callback) {
+  public final void to(AsyncCallback<T> callback) {
     try {
       final T r = call();
       if (r != null) {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
index 5315182..b03609e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/RpcServletModule.java
@@ -26,7 +26,7 @@
 
   private final String prefix;
 
-  protected RpcServletModule(final String pathPrefix) {
+  protected RpcServletModule(String pathPrefix) {
     prefix = pathPrefix;
   }
 
@@ -38,7 +38,7 @@
     rpc(name, clazz);
   }
 
-  protected void rpc(final String name, Class<? extends RemoteJsonService> clazz) {
+  protected void rpc(String name, Class<? extends RemoteJsonService> clazz) {
     final Key<GerritJsonServlet> srv = Key.get(GerritJsonServlet.class, UniqueAnnotations.create());
     final GerritJsonServletProvider provider = new GerritJsonServletProvider(clazz);
     bind(clazz);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
index ec67661..7a7713d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/SystemInfoServiceImpl.java
@@ -44,9 +44,9 @@
   }
 
   @Override
-  public void daemonHostKeys(final AsyncCallback<List<SshHostKey>> callback) {
+  public void daemonHostKeys(AsyncCallback<List<SshHostKey>> callback) {
     final ArrayList<SshHostKey> r = new ArrayList<>(hostKeys.size());
-    for (final HostKey hk : hostKeys) {
+    for (HostKey hk : hostKeys) {
       String host = hk.getHost();
       if (host.startsWith("*:")) {
         final String port = host.substring(2);
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
deleted file mode 100644
index 1604997..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/doc/QueryDocumentationFilter.java
+++ /dev/null
@@ -1,72 +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.doc;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
-import com.google.gerrit.server.documentation.QueryDocumentationExecutor.DocQueryException;
-import com.google.gerrit.server.documentation.QueryDocumentationExecutor.DocResult;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-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.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class QueryDocumentationFilter implements Filter {
-  private final Logger log = LoggerFactory.getLogger(QueryDocumentationFilter.class);
-
-  private final QueryDocumentationExecutor searcher;
-
-  @Inject
-  QueryDocumentationFilter(QueryDocumentationExecutor searcher) {
-    this.searcher = searcher;
-  }
-
-  @Override
-  public void init(FilterConfig filterConfig) {}
-
-  @Override
-  public void destroy() {}
-
-  @Override
-  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-      throws IOException, ServletException {
-    HttpServletRequest req = (HttpServletRequest) request;
-    if ("GET".equals(req.getMethod()) && !Strings.isNullOrEmpty(req.getParameter("q"))) {
-      HttpServletResponse rsp = (HttpServletResponse) response;
-      try {
-        List<DocResult> result = searcher.doQuery(request.getParameter("q"));
-        RestApiServlet.replyJson(req, rsp, ImmutableListMultimap.of(), result);
-      } catch (DocQueryException e) {
-        log.error("Doc search failed:", e);
-        rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      }
-    } else {
-      chain.doFilter(request, response);
-    }
-  }
-}
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 75026d3..2adf029 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
@@ -24,6 +24,8 @@
 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.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
@@ -61,6 +63,7 @@
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
       GitReferenceUpdated gitRefUpdated,
+      ContributorAgreementsChecker contributorAgreements,
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
       @Assisted List<AccessSection> sectionList,
@@ -77,6 +80,7 @@
         sectionList,
         parentProjectName,
         message,
+        contributorAgreements,
         true);
     this.projectAccessFactory = projectAccessFactory;
     this.projectCache = projectCache;
@@ -89,7 +93,8 @@
       ProjectConfig config,
       MetaDataUpdate md,
       boolean parentProjectUpdate)
-      throws IOException, NoSuchProjectException, ConfigInvalidException {
+      throws IOException, NoSuchProjectException, ConfigInvalidException,
+          PermissionBackendException {
     RevCommit commit = config.commit(md);
 
     gitRefUpdated.fire(
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 ca23ec2..4cd6fa0 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
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+import static com.google.gerrit.server.permissions.RefPermission.READ;
+
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GroupDescription;
@@ -24,21 +28,28 @@
 import com.google.gerrit.common.data.RefConfigSection;
 import com.google.gerrit.common.data.WebLinkInfoCommon;
 import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.httpd.rpc.Handler;
 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.WebLinks;
 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.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
 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.RefControl;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -56,27 +67,32 @@
 
   private final GroupBackend groupBackend;
   private final ProjectCache projectCache;
-  private final ProjectControl.Factory projectControlFactory;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final ProjectControl.GenericFactory projectControlFactory;
   private final GroupControl.Factory groupControlFactory;
   private final MetaDataUpdate.Server metaDataUpdateFactory;
   private final AllProjectsName allProjectsName;
 
   private final Project.NameKey projectName;
-  private ProjectControl pc;
   private WebLinks webLinks;
 
   @Inject
   ProjectAccessFactory(
-      final GroupBackend groupBackend,
-      final ProjectCache projectCache,
-      final ProjectControl.Factory projectControlFactory,
-      final GroupControl.Factory groupControlFactory,
-      final MetaDataUpdate.Server metaDataUpdateFactory,
-      final AllProjectsName allProjectsName,
-      final WebLinks webLinks,
+      GroupBackend groupBackend,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      ProjectControl.GenericFactory projectControlFactory,
+      GroupControl.Factory groupControlFactory,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      AllProjectsName allProjectsName,
+      WebLinks webLinks,
       @Assisted final Project.NameKey name) {
     this.groupBackend = groupBackend;
     this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.projectControlFactory = projectControlFactory;
     this.groupControlFactory = groupControlFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -87,8 +103,10 @@
   }
 
   @Override
-  public ProjectAccess call() throws NoSuchProjectException, IOException, ConfigInvalidException {
-    pc = open();
+  public ProjectAccess call()
+      throws NoSuchProjectException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    ProjectControl pc = checkProjectControl();
 
     // Load the current configuration from the repository, ensuring its the most
     // recent version available. If it differs from what was in the project
@@ -97,23 +115,23 @@
     ProjectConfig 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();
+        pc = checkProjectControl();
       } else if (config.getRevision() != null
           && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
         projectCache.evict(config.getProject());
-        pc = open();
+        pc = checkProjectControl();
       }
     }
 
-    final RefControl metaConfigControl = pc.controlForRef(RefNames.REFS_CONFIG);
     List<AccessSection> local = new ArrayList<>();
     Set<String> ownerOf = new HashSet<>();
     Map<AccountGroup.UUID, Boolean> visibleGroups = new HashMap<>();
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(projectName);
+    boolean checkReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
 
     for (AccessSection section : config.getAccessSections()) {
       String name = section.getName();
@@ -122,20 +140,19 @@
           local.add(section);
           ownerOf.add(name);
 
-        } else if (metaConfigControl.isVisible()) {
+        } else if (checkReadConfig) {
           local.add(section);
         }
 
       } else if (RefConfigSection.isValid(name)) {
-        RefControl rc = pc.controlForRef(name);
-        if (rc.isOwner()) {
+        if (pc.controlForRef(name).isOwner()) {
           local.add(section);
           ownerOf.add(name);
 
-        } else if (metaConfigControl.isVisible()) {
+        } else if (checkReadConfig) {
           local.add(section);
 
-        } else if (rc.isVisible()) {
+        } else if (check(perm, name, READ)) {
           // 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
@@ -177,10 +194,9 @@
       }
     }
 
-    if (ownerOf.isEmpty() && pc.isOwnerAnyRef()) {
+    if (ownerOf.isEmpty() && isAdmin()) {
       // 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.
+      // access control information. Fall back to site administrators.
       ownerOf.add(AccessSection.ALL);
     }
 
@@ -193,19 +209,19 @@
 
     detail.setInheritsFrom(config.getProject().getParent(allProjectsName));
 
-    if (projectName.equals(allProjectsName)) {
-      if (pc.isOwner()) {
-        ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
-      }
+    if (projectName.equals(allProjectsName)
+        && permissionBackend.user(user).testOrFalse(ADMINISTRATE_SERVER)) {
+      ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
     }
 
     detail.setLocal(local);
     detail.setOwnerOf(ownerOf);
     detail.setCanUpload(
-        metaConfigControl.isVisible() && (pc.isOwner() || metaConfigControl.canUpload()));
-    detail.setConfigVisible(pc.isOwner() || metaConfigControl.isVisible());
+        pc.isOwner()
+            || (checkReadConfig && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)));
+    detail.setConfigVisible(pc.isOwner() || checkReadConfig);
     detail.setGroupInfo(buildGroupInfo(local));
-    detail.setLabelTypes(pc.getLabelTypes());
+    detail.setLabelTypes(pc.getProjectState().getLabelTypes());
     detail.setFileHistoryLinks(getConfigFileLogLinks(projectName.get()));
     return detail;
   }
@@ -235,9 +251,33 @@
     return Maps.filterEntries(infos, in -> in.getValue() != null);
   }
 
-  private ProjectControl open() throws NoSuchProjectException {
-    return projectControlFactory.validateFor( //
-        projectName, //
-        ProjectControl.OWNER | ProjectControl.VISIBLE);
+  private ProjectControl checkProjectControl()
+      throws NoSuchProjectException, IOException, PermissionBackendException {
+    ProjectControl pc = projectControlFactory.controlFor(projectName, user.get());
+    try {
+      permissionBackend.user(user).project(projectName).check(ProjectPermission.ACCESS);
+    } catch (AuthException e) {
+      throw new NoSuchProjectException(projectName);
+    }
+    return pc;
+  }
+
+  private static boolean check(PermissionBackend.ForProject ctx, String ref, RefPermission perm)
+      throws PermissionBackendException {
+    try {
+      ctx.ref(ref).check(perm);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
+
+  private boolean isAdmin() throws PermissionBackendException {
+    try {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 }
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 0d90190..3fa05ab 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,7 +18,6 @@
 
 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;
@@ -36,6 +35,8 @@
 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.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefPattern;
@@ -57,6 +58,7 @@
   private final MetaDataUpdate.User metaDataUpdateFactory;
   private final AllProjectsName allProjects;
   private final Provider<SetParent> setParent;
+  private final ContributorAgreementsChecker contributorAgreements;
 
   protected final Project.NameKey projectName;
   protected final ObjectId base;
@@ -76,6 +78,7 @@
       List<AccessSection> sectionList,
       Project.NameKey parentProjectName,
       String message,
+      ContributorAgreementsChecker contributorAgreements,
       boolean checkIfOwner) {
     this.projectControlFactory = projectControlFactory;
     this.groupBackend = groupBackend;
@@ -88,6 +91,7 @@
     this.sectionList = sectionList;
     this.parentProjectName = parentProjectName;
     this.message = message;
+    this.contributorAgreements = contributorAgreements;
     this.checkIfOwner = checkIfOwner;
   }
 
@@ -95,12 +99,13 @@
   public final T call()
       throws NoSuchProjectException, IOException, ConfigInvalidException, InvalidNameException,
           NoSuchGroupException, OrmException, UpdateParentFailedException,
-          PermissionDeniedException {
+          PermissionDeniedException, PermissionBackendException {
     final ProjectControl projectControl = projectControlFactory.controlFor(projectName);
 
-    Capable r = projectControl.canPushToAtLeastOneRef();
-    if (r != Capable.OK) {
-      throw new PermissionDeniedException(r.getMessage());
+    try {
+      contributorAgreements.check(projectName, projectControl.getUser());
+    } catch (AuthException e) {
+      throw new PermissionDeniedException(e.getMessage());
     }
 
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
@@ -146,7 +151,8 @@
           setParent
               .get()
               .validateParentUpdate(
-                  projectControl,
+                  projectControl.getProject().getNameKey(),
+                  projectControl.getUser().asIdentifiedUser(),
                   MoreObjects.firstNonNull(parentProjectName, allProjects).get(),
                   checkIfOwner);
         } catch (AuthException e) {
@@ -182,7 +188,7 @@
       MetaDataUpdate md,
       boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException, OrmException,
-          PermissionDeniedException;
+          PermissionDeniedException, PermissionBackendException;
 
   private void replace(ProjectConfig config, Set<String> toDelete, AccessSection section)
       throws NoSuchGroupException {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
index bdb274d..da471c3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAdminServiceImpl.java
@@ -41,11 +41,11 @@
 
   @Override
   public void projectAccess(
-      final Project.NameKey projectName, final AsyncCallback<ProjectAccess> callback) {
+      final Project.NameKey projectName, AsyncCallback<ProjectAccess> callback) {
     projectAccessFactory.create(projectName).to(callback);
   }
 
-  private static ObjectId getBase(final String baseRevision) {
+  private static ObjectId getBase(String baseRevision) {
     if (baseRevision != null && !baseRevision.isEmpty()) {
       return ObjectId.fromString(baseRevision);
     }
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 9ad1250..f27b9d3 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,6 +14,7 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
+import com.google.common.base.Throwables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.AccessSection;
@@ -21,6 +22,7 @@
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
@@ -37,11 +39,13 @@
 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.git.validators.CommitValidators;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.project.SetParent;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
@@ -53,6 +57,7 @@
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -67,6 +72,7 @@
   }
 
   private final ReviewDb db;
+  private final PermissionBackend permissionBackend;
   private final Sequences seq;
   private final Provider<PostReviewers> reviewersProvider;
   private final ProjectCache projectCache;
@@ -77,6 +83,7 @@
   @Inject
   ReviewProjectAccess(
       final ProjectControl.Factory projectControlFactory,
+      PermissionBackend permissionBackend,
       GroupBackend groupBackend,
       MetaDataUpdate.User metaDataUpdateFactory,
       ReviewDb db,
@@ -88,6 +95,7 @@
       BatchUpdate.Factory updateFactory,
       Provider<SetParent> setParent,
       Sequences seq,
+      ContributorAgreementsChecker contributorAgreements,
       @Assisted("projectName") Project.NameKey projectName,
       @Nullable @Assisted ObjectId base,
       @Assisted List<AccessSection> sectionList,
@@ -104,8 +112,10 @@
         sectionList,
         parentProjectName,
         message,
+        contributorAgreements,
         false);
     this.db = db;
+    this.permissionBackend = permissionBackend;
     this.seq = seq;
     this.reviewersProvider = reviewersProvider;
     this.projectCache = projectCache;
@@ -114,19 +124,32 @@
     this.updateFactory = updateFactory;
   }
 
+  // TODO(dborowitz): Hack MetaDataUpdate so it can be created within a BatchUpdate and we can avoid
+  // calling setUpdateRef(false).
+  @SuppressWarnings("deprecation")
   @Override
   protected Change.Id updateProjectConfig(
       ProjectControl projectControl,
       ProjectConfig config,
       MetaDataUpdate md,
       boolean parentProjectUpdate)
-      throws IOException, OrmException, PermissionDeniedException {
-    RefControl refsMetaConfigControl = projectControl.controlForRef(RefNames.REFS_CONFIG);
-    if (!refsMetaConfigControl.isVisible()) {
+      throws IOException, OrmException, PermissionDeniedException, PermissionBackendException {
+    PermissionBackend.ForRef metaRef =
+        permissionBackend
+            .user(projectControl.getUser())
+            .project(projectControl.getProject().getNameKey())
+            .ref(RefNames.REFS_CONFIG);
+    try {
+      metaRef.check(RefPermission.READ);
+    } catch (AuthException denied) {
       throw new PermissionDeniedException(RefNames.REFS_CONFIG + " not visible");
     }
-    if (!projectControl.isOwner() && !refsMetaConfigControl.canUpload()) {
-      throw new PermissionDeniedException("cannot upload to " + RefNames.REFS_CONFIG);
+    if (!projectControl.isOwner()) {
+      try {
+        metaRef.check(RefPermission.CREATE_CHANGE);
+      } catch (AuthException denied) {
+        throw new PermissionDeniedException("cannot create change for " + RefNames.REFS_CONFIG);
+      }
     }
 
     md.setInsertChangeId(true);
@@ -138,8 +161,9 @@
       return null;
     }
 
-    try (RevWalk rw = new RevWalk(md.getRepository());
-        ObjectInserter objInserter = md.getRepository().newObjectInserter();
+    try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+        ObjectReader objReader = objInserter.newReader();
+        RevWalk rw = new RevWalk(objReader);
         BatchUpdate bu =
             updateFactory.create(
                 db, config.getProject().getNameKey(), projectControl.getUser(), TimeUtil.nowTs())) {
@@ -147,7 +171,7 @@
       bu.insertChange(
           changeInserterFactory
               .create(changeId, commit, RefNames.REFS_CONFIG)
-              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setValidate(false)
               .setUpdateRef(false)); // Created by commitToNewRef.
       bu.execute();
     } catch (UpdateException | RestApiException e) {
@@ -173,9 +197,10 @@
       AddReviewerInput input = new AddReviewerInput();
       input.reviewer = projectOwners;
       reviewersProvider.get().apply(rsrc, input);
-    } catch (IOException | OrmException | RestApiException | UpdateException e) {
+    } catch (Exception e) {
       // one of the owner groups is not visible to the user and this it why it
       // can't be added as reviewer
+      Throwables.throwIfUnchecked(e);
     }
   }
 
@@ -192,8 +217,9 @@
         AddReviewerInput input = new AddReviewerInput();
         input.reviewer = r.getGroup().getUUID().get();
         reviewersProvider.get().apply(rsrc, input);
-      } catch (IOException | OrmException | RestApiException | UpdateException e) {
+      } catch (Exception e) {
         // ignore
+        Throwables.throwIfUnchecked(e);
       }
     }
   }
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
index e475fd1..39900e8 100644
--- a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
+++ b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/auth/ldap/LoginForm.html
@@ -15,7 +15,7 @@
     </style>
     <style id="gerrit_sitecss" type="text/css"></style>
   </head>
-  <body>
+  <body class="login" id="login_ldap">
     <div id="gerrit_topmenu" style="height:45px;" class="gerritTopMenu"></div>
     <div id="gerrit_header"></div>
     <div id="gerrit_body" class="gerritBody">
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 99c3454..497039d 100644
--- a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -19,6 +19,7 @@
 /**
  * @param canonicalPath
  * @param staticResourcePath
+ * @param? versionInfo
  */
 {template .Index autoescape="strict" kind="html"}
   <!DOCTYPE html>{\n}
@@ -31,19 +32,31 @@
     To use PolyGerrit, please enable JavaScript in your browser settings, and then refresh this page.
   </noscript>
 
-  {if $canonicalPath != ''}
-    <script>window.CANONICAL_PATH = '{$canonicalPath}';</script>{\n}
-  {/if}
+  <script>
+    window.CLOSURE_NO_DEPS = true;
+    {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
+    {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
+  </script>{\n}
 
   <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
 
-  // SourceCodePro fonts are used in styles/fonts.css
+  // RobotoMono fonts are used in styles/fonts.css
   // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
-  <link rel="preload" href="{$staticResourcePath}/fonts/SourceCodePro-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
-  <link rel="preload" href="{$staticResourcePath}/fonts/SourceCodePro-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin="anonymous">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
   <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
+  // Content between webcomponents-lite and the load of the main app element
+  // run before polymer-resin is installed so may have security consequences.
+  // Contact your local security engineer if you have any questions, and
+  // CC them on any changes that load content before gr-app.html.
+  //
+  // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
   <link rel="preload" href="{$staticResourcePath}/elements/gr-app.js" as="script" crossorigin="anonymous">{\n}
   <link rel="import" href="{$staticResourcePath}/elements/gr-app.html">{\n}
 
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index 86989dd..086dcc2 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -74,7 +74,7 @@
    * <p>This method adds the given filter to all {@link AllRequestFilter.FilterProxy} instances
    * created by {@link #getFilterProxy()}.
    */
-  private ReloadableRegistrationHandle<AllRequestFilter> addFilter(final AllRequestFilter filter) {
+  private ReloadableRegistrationHandle<AllRequestFilter> addFilter(AllRequestFilter filter) {
     Key<AllRequestFilter> key = Key.get(AllRequestFilter.class);
     return filters.add(key, Providers.of(filter));
   }
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java
index 7133cf6..abf890e 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,12 +15,25 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.template.soy.data.SoyMapData;
 import java.net.URISyntaxException;
 import org.junit.Test;
 
 public class IndexServletTest {
+  static class TestIndexServlet extends IndexServlet {
+    private static final long serialVersionUID = 1L;
+
+    TestIndexServlet(String canonicalURL, String cdnPath) throws URISyntaxException {
+      super(canonicalURL, cdnPath);
+    }
+
+    String getIndexSource() {
+      return new String(indexSource, UTF_8);
+    }
+  }
+
   @Test
   public void noPathAndNoCDN() throws URISyntaxException {
     SoyMapData data = IndexServlet.getTemplateData("http://example.com/", null);
@@ -52,4 +65,15 @@
     assertThat(data.getSingle("staticResourcePath").stringValue())
         .isEqualTo("http://my-cdn.com/foo/bar/");
   }
+
+  @Test
+  public void renderTemplate() throws URISyntaxException {
+    String testCanonicalUrl = "foo-url";
+    String testCdnPath = "bar-cdn";
+    TestIndexServlet servlet = new TestIndexServlet(testCanonicalUrl, testCdnPath);
+    String output = servlet.getIndexSource();
+    assertThat(output).contains("<!DOCTYPE html>");
+    assertThat(output).contains("window.CANONICAL_PATH = '" + testCanonicalUrl);
+    assertThat(output).contains("<link rel=\"preload\" href=\"" + testCdnPath);
+  }
 }
diff --git a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java
index 2b724e2..13732b0 100644
--- a/gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java
+++ b/gerrit-httpd/src/test/java/com/google/gerrit/httpd/restapi/ParameterParserTest.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.httpd.restapi;
 
+import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
@@ -49,4 +53,91 @@
 
     assertEquals(exp, obj);
   }
+
+  @Test
+  public void parseQuery() throws BadRequestException {
+    FakeHttpServletRequest req = new FakeHttpServletRequest();
+    req.setQueryString("query=status%3aopen");
+    QueryParams qp = ParameterParser.getQueryParams(req);
+    assertThat(qp.accessToken()).isNull();
+    assertThat(qp.xdMethod()).isNull();
+    assertThat(qp.xdContentType()).isNull();
+    assertThat(qp.hasXdOverride()).isFalse();
+    assertThat(qp.config()).isEmpty();
+    assertThat(qp.params()).containsKey("query");
+    assertThat(qp.params().get("query")).containsExactly("status:open");
+  }
+
+  @Test
+  public void parseAccessToken() throws BadRequestException {
+    FakeHttpServletRequest req = new FakeHttpServletRequest();
+    req.setQueryString("query=status%3aopen&access_token=secr%65t");
+    QueryParams qp = ParameterParser.getQueryParams(req);
+    assertThat(qp.accessToken()).isEqualTo("secret");
+    assertThat(qp.xdMethod()).isNull();
+    assertThat(qp.xdContentType()).isNull();
+    assertThat(qp.hasXdOverride()).isFalse();
+    assertThat(qp.config()).isEmpty();
+    assertThat(qp.params()).containsKey("query");
+    assertThat(qp.params().get("query")).containsExactly("status:open");
+
+    req = new FakeHttpServletRequest();
+    req.setQueryString("access_token=secret");
+    qp = ParameterParser.getQueryParams(req);
+    assertThat(qp.accessToken()).isEqualTo("secret");
+    assertThat(qp.xdMethod()).isNull();
+    assertThat(qp.xdContentType()).isNull();
+    assertThat(qp.hasXdOverride()).isFalse();
+    assertThat(qp.config()).isEmpty();
+    assertThat(qp.params()).isEmpty();
+  }
+
+  @Test
+  public void parseXdOverride() throws BadRequestException {
+    FakeHttpServletRequest req = new FakeHttpServletRequest();
+    req.setQueryString("$m=PUT&$ct=json&access_token=secret");
+    QueryParams qp = ParameterParser.getQueryParams(req);
+    assertThat(qp.accessToken()).isEqualTo("secret");
+    assertThat(qp.xdMethod()).isEqualTo("PUT");
+    assertThat(qp.xdContentType()).isEqualTo("json");
+    assertThat(qp.hasXdOverride()).isTrue();
+    assertThat(qp.config()).isEmpty();
+    assertThat(qp.params()).isEmpty();
+  }
+
+  @Test
+  public void rejectDuplicateMethod() {
+    FakeHttpServletRequest req = new FakeHttpServletRequest();
+    req.setQueryString("$m=PUT&$m=DELETE");
+    try {
+      ParameterParser.getQueryParams(req);
+      fail("expected BadRequestException");
+    } catch (BadRequestException bad) {
+      assertThat(bad).hasMessageThat().isEqualTo("duplicate $m");
+    }
+  }
+
+  @Test
+  public void rejectDuplicateContentType() {
+    FakeHttpServletRequest req = new FakeHttpServletRequest();
+    req.setQueryString("$ct=json&$ct=string");
+    try {
+      ParameterParser.getQueryParams(req);
+      fail("expected BadRequestException");
+    } catch (BadRequestException bad) {
+      assertThat(bad).hasMessageThat().isEqualTo("duplicate $ct");
+    }
+  }
+
+  @Test
+  public void rejectInvalidMethod() {
+    FakeHttpServletRequest req = new FakeHttpServletRequest();
+    req.setQueryString("$m=CONNECT");
+    try {
+      ParameterParser.getQueryParams(req);
+      fail("expected BadRequestException");
+    } catch (BadRequestException bad) {
+      assertThat(bad).hasMessageThat().isEqualTo("invalid $m");
+    }
+  }
 }
diff --git a/gerrit-index/BUILD b/gerrit-index/BUILD
new file mode 100644
index 0000000..0ba527f
--- /dev/null
+++ b/gerrit-index/BUILD
@@ -0,0 +1,79 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+QUERY_PARSE_EXCEPTION_SRCS = [
+    "src/main/java/com/google/gerrit/index/query/QueryParseException.java",
+    "src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java",
+]
+
+java_library(
+    name = "query_exception",
+    srcs = QUERY_PARSE_EXCEPTION_SRCS,
+    visibility = ["//visibility:public"],
+)
+
+genrule2(
+    name = "query_antlr",
+    srcs = ["src/main/antlr3/com/google/gerrit/index/query/Query.g"],
+    outs = ["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 = ["//gerrit-plugin-api:__pkg__"],
+    deps = [
+        ":query_exception",
+        "//lib/antlr:java-runtime",
+    ],
+)
+
+java_library(
+    name = "index",
+    srcs = glob(
+        ["src/main/java/**/*.java"],
+        exclude = QUERY_PARSE_EXCEPTION_SRCS,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":query_exception",
+        ":query_parser",
+        "//gerrit-common:annotations",
+        "//gerrit-extension-api:api",
+        "//gerrit-server:metrics",
+        "//lib:guava",
+        "//lib:gwtjsonrpc",
+        "//lib:gwtorm",
+        "//lib/antlr:java-runtime",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
+
+junit_tests(
+    name = "index_tests",
+    size = "small",
+    srcs = glob(["src/test/java/**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":index",
+        ":query_exception",
+        ":query_parser",
+        "//lib:junit",
+        "//lib:truth",
+        "//lib/antlr:java-runtime",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/gerrit-index/src/main/antlr3/com/google/gerrit/index/query/Query.g b/gerrit-index/src/main/antlr3/com/google/gerrit/index/query/Query.g
new file mode 100644
index 0000000..953a473
--- /dev/null
+++ b/gerrit-index/src/main/antlr3/com/google/gerrit/index/query/Query.g
@@ -0,0 +1,185 @@
+// 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.
+
+grammar Query;
+options {
+  language = Java;
+  output = AST;
+}
+
+tokens {
+  AND;
+  OR;
+  NOT;
+  DEFAULT_FIELD;
+}
+
+@header {
+package com.google.gerrit.index.query;
+}
+@members {
+  static class QueryParseInternalException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    QueryParseInternalException(final String msg) {
+      super(msg);
+    }
+  }
+
+  public static Tree parse(final String str)
+    throws QueryParseException {
+    try {
+      final QueryParser p = new QueryParser(
+        new TokenRewriteStream(
+          new QueryLexer(
+            new ANTLRStringStream(str)
+          )
+        )
+      );
+      return (Tree)p.query().getTree();
+    } catch (QueryParseInternalException e) {
+      throw new QueryParseException(e.getMessage());
+    } catch (RecognitionException e) {
+      throw new QueryParseException(e.getMessage());
+    }
+  }
+
+  public static boolean isSingleWord(final String value) {
+    try {
+      final QueryLexer lexer = new QueryLexer(new ANTLRStringStream(value));
+      lexer.mSINGLE_WORD();
+      return lexer.nextToken().getType() == QueryParser.EOF;
+    } catch (QueryParseInternalException e) {
+      return false;
+    } catch (RecognitionException e) {
+      return false;
+    }
+  }
+
+  @Override
+  public void displayRecognitionError(String[] tokenNames,
+                                      RecognitionException e) {
+      String hdr = getErrorHeader(e);
+      String msg = getErrorMessage(e, tokenNames);
+      throw new QueryParseInternalException(hdr + " " + msg);
+  }
+}
+
+@lexer::header {
+package com.google.gerrit.index.query;
+}
+@lexer::members {
+  @Override
+  public void displayRecognitionError(String[] tokenNames,
+                                      RecognitionException e) {
+      String hdr = getErrorHeader(e);
+      String msg = getErrorMessage(e, tokenNames);
+      throw new QueryParser.QueryParseInternalException(hdr + " " + msg);
+  }
+}
+
+query
+  : conditionOr
+  ;
+
+conditionOr
+  : (conditionAnd OR)
+    => conditionAnd OR^ conditionAnd (OR! conditionAnd)*
+  | conditionAnd
+  ;
+
+conditionAnd
+  : (conditionNot AND)
+    => i+=conditionNot (i+=conditionAnd2)*
+    -> ^(AND $i+)
+  | (conditionNot conditionNot)
+    => i+=conditionNot (i+=conditionAnd2)*
+    -> ^(AND $i+)
+  | conditionNot
+  ;
+conditionAnd2
+  : AND! conditionNot
+  | conditionNot
+  ;
+
+conditionNot
+  : '-' conditionBase -> ^(NOT conditionBase)
+  | NOT^ conditionBase
+  | conditionBase
+  ;
+conditionBase
+  : '('! conditionOr ')'!
+  | (FIELD_NAME ':') => FIELD_NAME^ ':'! fieldValue
+  | fieldValue -> ^(DEFAULT_FIELD fieldValue)
+  ;
+
+fieldValue
+  : n=FIELD_NAME   -> SINGLE_WORD[n]
+  | SINGLE_WORD
+  | EXACT_PHRASE
+  ;
+
+AND: 'AND' ;
+OR:  'OR'  ;
+NOT: 'NOT' ;
+
+WS
+  :  ( ' ' | '\r' | '\t' | '\n' ) { $channel=HIDDEN; }
+  ;
+
+FIELD_NAME
+  : ('a'..'z' | '_')+
+  ;
+
+EXACT_PHRASE
+  : '"' ( ~('"') )* '"' {
+      String s = $text;
+      setText(s.substring(1, s.length() - 1));
+    }
+  | '{' ( ~('{'|'}') )* '}' {
+      String s = $text;
+      setText(s.substring(1, s.length() - 1));
+    }
+  ;
+
+SINGLE_WORD
+  : ~( '-' | NON_WORD ) ( ~( NON_WORD ) )*
+  ;
+fragment NON_WORD
+  :  ( '\u0000'..' '
+     | '!'
+     | '"'
+     // '#' permit
+     | '$'
+     | '%'
+     | '&'
+     | '\''
+     | '(' | ')'
+     // '*'  permit
+     // '+'  permit
+     // ','  permit
+     // '-'  permit
+     // '.'  permit
+     // '/'  permit
+     | ':'
+     | ';'
+     // '<' permit
+     // '=' permit
+     // '>' permit
+     | '?'
+     | '[' | ']'
+     | '{' | '}'
+     // | '~' permit
+     )
+  ;
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/FieldDef.java b/gerrit-index/src/main/java/com/google/gerrit/index/FieldDef.java
new file mode 100644
index 0000000..b1ffac1
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/FieldDef.java
@@ -0,0 +1,148 @@
+// 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.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.CharMatcher;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.sql.Timestamp;
+
+/**
+ * Definition of a field stored in the secondary index.
+ *
+ * @param <I> input type from which documents are created and search results are returned.
+ * @param <T> type that should be extracted from the input object when converting to an index
+ *     document.
+ */
+public final class FieldDef<I, T> {
+  public static FieldDef.Builder<String> exact(String name) {
+    return new FieldDef.Builder<>(FieldType.EXACT, name);
+  }
+
+  public static FieldDef.Builder<String> fullText(String name) {
+    return new FieldDef.Builder<>(FieldType.FULL_TEXT, name);
+  }
+
+  public static FieldDef.Builder<Integer> intRange(String name) {
+    return new FieldDef.Builder<>(FieldType.INTEGER_RANGE, name).stored();
+  }
+
+  public static FieldDef.Builder<Integer> integer(String name) {
+    return new FieldDef.Builder<>(FieldType.INTEGER, name);
+  }
+
+  public static FieldDef.Builder<String> prefix(String name) {
+    return new FieldDef.Builder<>(FieldType.PREFIX, name);
+  }
+
+  public static FieldDef.Builder<byte[]> storedOnly(String name) {
+    return new FieldDef.Builder<>(FieldType.STORED_ONLY, name).stored();
+  }
+
+  public static FieldDef.Builder<Timestamp> timestamp(String name) {
+    return new FieldDef.Builder<>(FieldType.TIMESTAMP, name);
+  }
+
+  @FunctionalInterface
+  public interface Getter<I, T> {
+    T get(I input) throws OrmException, IOException;
+  }
+
+  public static class Builder<T> {
+    private final FieldType<T> type;
+    private final String name;
+    private boolean stored;
+
+    public Builder(FieldType<T> type, String name) {
+      this.type = checkNotNull(type);
+      this.name = checkNotNull(name);
+    }
+
+    public Builder<T> stored() {
+      this.stored = true;
+      return this;
+    }
+
+    public <I> FieldDef<I, T> build(Getter<I, T> getter) {
+      return new FieldDef<>(name, type, stored, false, getter);
+    }
+
+    public <I> FieldDef<I, Iterable<T>> buildRepeatable(Getter<I, Iterable<T>> getter) {
+      return new FieldDef<>(name, type, stored, true, getter);
+    }
+  }
+
+  private final String name;
+  private final FieldType<?> type;
+  private final boolean stored;
+  private final boolean repeatable;
+  private final Getter<I, T> getter;
+
+  private FieldDef(
+      String name, FieldType<?> type, boolean stored, boolean repeatable, Getter<I, T> getter) {
+    checkArgument(
+        !(repeatable && type == FieldType.INTEGER_RANGE),
+        "Range queries against repeated fields are unsupported");
+    this.name = checkName(name);
+    this.type = checkNotNull(type);
+    this.stored = stored;
+    this.repeatable = repeatable;
+    this.getter = checkNotNull(getter);
+  }
+
+  private static String checkName(String name) {
+    CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
+    checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
+    return name;
+  }
+
+  /** @return name of the field. */
+  public String getName() {
+    return name;
+  }
+
+  /** @return type of the field; for repeatable fields, the inner type, not the iterable type. */
+  public FieldType<?> getType() {
+    return type;
+  }
+
+  /** @return whether the field should be stored in the index. */
+  public boolean isStored() {
+    return stored;
+  }
+
+  /**
+   * Get the field contents from the input object.
+   *
+   * @param input input object.
+   * @return the field value(s) to index.
+   * @throws OrmException
+   */
+  public T get(I input) throws OrmException {
+    try {
+      return getter.get(input);
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  /** @return whether the field is repeatable. */
+  public boolean isRepeatable() {
+    return repeatable;
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/FieldType.java b/gerrit-index/src/main/java/com/google/gerrit/index/FieldType.java
new file mode 100644
index 0000000..0db0284
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/FieldType.java
@@ -0,0 +1,63 @@
+// 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.index;
+
+import java.sql.Timestamp;
+
+/** Document field types supported by the secondary index system. */
+public class FieldType<T> {
+  /** A single integer-valued field. */
+  public static final FieldType<Integer> INTEGER = new FieldType<>("INTEGER");
+
+  /** A single-integer-valued field matched using range queries. */
+  public static final FieldType<Integer> INTEGER_RANGE = new FieldType<>("INTEGER_RANGE");
+
+  /** A single integer-valued field. */
+  public static final FieldType<Long> LONG = new FieldType<>("LONG");
+
+  /** A single date/time-valued field. */
+  public static final FieldType<Timestamp> TIMESTAMP = new FieldType<>("TIMESTAMP");
+
+  /** A string field searched using exact-match semantics. */
+  public static final FieldType<String> EXACT = new FieldType<>("EXACT");
+
+  /** A string field searched using prefix. */
+  public static final FieldType<String> PREFIX = new FieldType<>("PREFIX");
+
+  /** A string field searched using fuzzy-match semantics. */
+  public static final FieldType<String> FULL_TEXT = new FieldType<>("FULL_TEXT");
+
+  /** A field that is only stored as raw bytes and cannot be queried. */
+  public static final FieldType<byte[]> STORED_ONLY = new FieldType<>("STORED_ONLY");
+
+  private final String name;
+
+  private FieldType(String name) {
+    this.name = name;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  @Override
+  public String toString() {
+    return name;
+  }
+
+  public static IllegalArgumentException badFieldType(FieldType<?> t) {
+    return new IllegalArgumentException("unknown index field type " + t);
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/Index.java b/gerrit-index/src/main/java/com/google/gerrit/index/Index.java
new file mode 100644
index 0000000..34f7d33
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/Index.java
@@ -0,0 +1,130 @@
+// 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.index;
+
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Secondary index implementation for arbitrary documents.
+ *
+ * <p>Documents are inserted into the index and are queried by converting special {@link
+ * com.google.gerrit.index.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();
+
+  /** 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;
+
+  /**
+   * Get a single document from the index.
+   *
+   * @param key document key.
+   * @param opts query options. Options that do not make sense in the context of a single document,
+   *     such as start, will be ignored.
+   * @return a single document if present.
+   * @throws IOException
+   */
+  default Optional<V> get(K key, QueryOptions opts) throws IOException {
+    opts = opts.withStart(0).withLimit(2);
+    List<V> results;
+    try {
+      results = getSource(keyPredicate(key), opts).read().toList();
+    } catch (QueryParseException e) {
+      throw new IOException("Unexpected QueryParseException during get()", e);
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+    switch (results.size()) {
+      case 0:
+        return Optional.empty();
+      case 1:
+        return Optional.of(results.get(0));
+      default:
+        throw new IOException("Multiple results found in index for key " + key + ": " + results);
+    }
+  }
+
+  /**
+   * Get a predicate that looks up a single document by key.
+   *
+   * @param key document key.
+   * @return a single predicate.
+   */
+  Predicate<V> keyPredicate(K key);
+
+  /**
+   * 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-index/src/main/java/com/google/gerrit/index/IndexCollection.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexCollection.java
new file mode 100644
index 0000000..2837f7e
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/IndexCollection.java
@@ -0,0 +1,102 @@
+// 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.index;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Dynamic pointers to the index versions used for searching and writing. */
+public abstract class IndexCollection<K, V, I extends Index<K, V>> implements LifecycleListener {
+  private final CopyOnWriteArrayList<I> writeIndexes;
+  private final AtomicReference<I> searchIndex;
+
+  protected IndexCollection() {
+    this.writeIndexes = Lists.newCopyOnWriteArrayList();
+    this.searchIndex = new AtomicReference<>();
+  }
+
+  /** @return the current search index version. */
+  public I getSearchIndex() {
+    return searchIndex.get();
+  }
+
+  public void setSearchIndex(I index) {
+    I old = searchIndex.getAndSet(index);
+    if (old != null && old != index && !writeIndexes.contains(old)) {
+      old.close();
+    }
+  }
+
+  public Collection<I> getWriteIndexes() {
+    return Collections.unmodifiableCollection(writeIndexes);
+  }
+
+  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) {
+        return writeIndexes.set(i, index);
+      }
+    }
+    writeIndexes.add(index);
+    return null;
+  }
+
+  public synchronized void removeWriteIndex(int version) {
+    int removeIndex = -1;
+    for (int i = 0; i < writeIndexes.size(); i++) {
+      if (writeIndexes.get(i).getSchema().getVersion() == version) {
+        removeIndex = i;
+        break;
+      }
+    }
+    if (removeIndex >= 0) {
+      try {
+        writeIndexes.get(removeIndex).close();
+      } finally {
+        writeIndexes.remove(removeIndex);
+      }
+    }
+  }
+
+  public I getWriteIndex(int version) {
+    for (I i : writeIndexes) {
+      if (i.getSchema().getVersion() == version) {
+        return i;
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public void start() {}
+
+  @Override
+  public void stop() {
+    I read = searchIndex.get();
+    if (read != null) {
+      read.close();
+    }
+    for (I write : writeIndexes) {
+      if (write != read) {
+        write.close();
+      }
+    }
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java
new file mode 100644
index 0000000..b53b59b
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java
@@ -0,0 +1,112 @@
+// 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.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import java.util.function.IntConsumer;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * 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 Index} and schema version.
+ */
+@AutoValue
+public abstract class IndexConfig {
+  private static final int DEFAULT_MAX_TERMS = 1024;
+
+  public static IndexConfig createDefault() {
+    return builder().build();
+  }
+
+  public static Builder fromConfig(Config cfg) {
+    Builder b = builder();
+    setIfPresent(cfg, "maxLimit", b::maxLimit);
+    setIfPresent(cfg, "maxPages", b::maxPages);
+    setIfPresent(cfg, "maxTerms", b::maxTerms);
+    return b;
+  }
+
+  private static void setIfPresent(Config cfg, String name, IntConsumer setter) {
+    int n = cfg.getInt("index", null, name, 0);
+    if (n != 0) {
+      setter.accept(n);
+    }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_IndexConfig.Builder()
+        .maxLimit(Integer.MAX_VALUE)
+        .maxPages(Integer.MAX_VALUE)
+        .maxTerms(DEFAULT_MAX_TERMS)
+        .separateChangeSubIndexes(false);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder maxLimit(int maxLimit);
+
+    abstract int maxLimit();
+
+    public abstract Builder maxPages(int maxPages);
+
+    abstract int maxPages();
+
+    public abstract Builder maxTerms(int maxTerms);
+
+    abstract int maxTerms();
+
+    public abstract Builder separateChangeSubIndexes(boolean separate);
+
+    abstract IndexConfig autoBuild();
+
+    public IndexConfig build() {
+      IndexConfig cfg = autoBuild();
+      checkLimit(cfg.maxLimit(), "maxLimit");
+      checkLimit(cfg.maxPages(), "maxPages");
+      checkLimit(cfg.maxTerms(), "maxTerms");
+      return cfg;
+    }
+  }
+
+  private static void checkLimit(int limit, String name) {
+    checkArgument(limit > 0, "%s must be positive: %s", name, limit);
+  }
+
+  /**
+   * @return maximum limit supported by the underlying index, or limited for performance reasons.
+   */
+  public abstract int maxLimit();
+
+  /**
+   * @return maximum number of pages (limit / start) supported by the underlying index, or limited
+   *     for performance reasons.
+   */
+  public abstract int maxPages();
+
+  /**
+   * @return maximum number of total index query terms supported by the underlying index, or limited
+   *     for performance reasons.
+   */
+  public abstract int maxTerms();
+
+  /**
+   * @return whether different subsets of changes may be stored in different physical sub-indexes.
+   */
+  public abstract boolean separateChangeSubIndexes();
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexDefinition.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexDefinition.java
new file mode 100644
index 0000000..f283bf1
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/IndexDefinition.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.index;
+
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.gerrit.common.Nullable;
+
+/**
+ * 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,
+      @Nullable 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;
+  }
+
+  @Nullable
+  public final SiteIndexer<K, V, I> getSiteIndexer() {
+    return siteIndexer;
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexRewriter.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexRewriter.java
new file mode 100644
index 0000000..4d6a35b
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/IndexRewriter.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.index;
+
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+
+public interface IndexRewriter<T> {
+
+  Predicate<T> rewrite(Predicate<T> in, QueryOptions opts) throws QueryParseException;
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexedQuery.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexedQuery.java
new file mode 100644
index 0000000..050b4a9
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/IndexedQuery.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.index;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Paginated;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.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-index/src/main/java/com/google/gerrit/index/QueryOptions.java b/gerrit-index/src/main/java/com/google/gerrit/index/QueryOptions.java
new file mode 100644
index 0000000..b57fb5f
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/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.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-index/src/main/java/com/google/gerrit/index/Schema.java b/gerrit-index/src/main/java/com/google/gerrit/index/Schema.java
new file mode 100644
index 0000000..d20aed1
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/Schema.java
@@ -0,0 +1,210 @@
+// 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.index;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Function;
+import com.google.common.base.MoreObjects;
+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.gwtorm.server.OrmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** 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> {
+    private final FieldDef<T, ?> field;
+    private final Iterable<?> values;
+
+    private Values(FieldDef<T, ?> field, Iterable<?> values) {
+      this.field = field;
+      this.values = values;
+    }
+
+    public FieldDef<T, ?> getField() {
+      return field;
+    }
+
+    public Iterable<?> getValues() {
+      return values;
+    }
+  }
+
+  private static <T> FieldDef<T, ?> checkSame(FieldDef<T, ?> f1, FieldDef<T, ?> f2) {
+    checkState(f1 == f2, "Mismatched %s fields: %s != %s", f1.getName(), f1, f2);
+    return f1;
+  }
+
+  private final ImmutableMap<String, FieldDef<T, ?>> fields;
+  private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
+
+  private int version;
+
+  public Schema(Iterable<FieldDef<T, ?>> fields) {
+    this(0, fields);
+  }
+
+  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() {
+    return version;
+  }
+
+  /**
+   * Get all fields in this schema.
+   *
+   * <p>This is primarily useful for iteration. Most callers should prefer one of the helper methods
+   * {@link #getField(FieldDef, FieldDef...)} or {@link #hasField(FieldDef)} to looking up fields by
+   * name
+   *
+   * @return all fields in this schema indexed by name.
+   */
+  public final ImmutableMap<String, FieldDef<T, ?>> getFields() {
+    return fields;
+  }
+
+  /** @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.
+   * @param rest additional fields to look up.
+   * @return the first field in the schema matching {@code first} or {@code rest}, in order, or
+   *     absent if no field matches.
+   */
+  @SafeVarargs
+  public final Optional<FieldDef<T, ?>> getField(FieldDef<T, ?> first, FieldDef<T, ?>... rest) {
+    FieldDef<T, ?> field = fields.get(first.getName());
+    if (field != null) {
+      return Optional.of(checkSame(field, first));
+    }
+    for (FieldDef<T, ?> f : rest) {
+      field = fields.get(f.getName());
+      if (field != null) {
+        return Optional.of(checkSame(field, f));
+      }
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Check whether a field is present in this schema.
+   *
+   * @param field field to look up.
+   * @return whether the field is present.
+   */
+  public final boolean hasField(FieldDef<T, ?> field) {
+    FieldDef<T, ?> f = fields.get(field.getName());
+    if (f == null) {
+      return false;
+    }
+    checkSame(f, field);
+    return true;
+  }
+
+  /**
+   * Build all fields in the schema from an input object.
+   *
+   * <p>Null values are omitted, as are fields which cause errors, which are logged.
+   *
+   * @param obj input object.
+   * @return all non-null field values from the object.
+   */
+  public final Iterable<Values<T>> buildFields(T obj) {
+    return FluentIterable.from(fields.values())
+        .transform(
+            new Function<FieldDef<T, ?>, Values<T>>() {
+              @Override
+              public Values<T> apply(FieldDef<T, ?> f) {
+                Object v;
+                try {
+                  v = f.get(obj);
+                } catch (OrmException e) {
+                  log.error("error getting field {} of {}", f.getName(), obj, e);
+                  return null;
+                }
+                if (v == null) {
+                  return null;
+                } else if (f.isRepeatable()) {
+                  return new Values<>(f, (Iterable<?>) v);
+                } else {
+                  return new Values<>(f, Collections.singleton(v));
+                }
+              }
+            })
+        .filter(Predicates.notNull());
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).addValue(fields.keySet()).toString();
+  }
+
+  public void setVersion(int version) {
+    this.version = version;
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/SchemaDefinitions.java b/gerrit-index/src/main/java/com/google/gerrit/index/SchemaDefinitions.java
new file mode 100644
index 0000000..f9c690c
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/SchemaDefinitions.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.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+
+/**
+ * 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();
+  }
+
+  @Nullable
+  public final Schema<V> getPrevious() {
+    if (schemas.size() <= 1) {
+      return null;
+    }
+    return Iterables.get(schemas.descendingMap().values(), 1);
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/SchemaUtil.java b/gerrit-index/src/main/java/com/google/gerrit/index/SchemaUtil.java
new file mode 100644
index 0000000..c59f251
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/SchemaUtil.java
@@ -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.
+
+package com.google.gerrit.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 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.Locale;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.PersonIdent;
+
+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 getNameParts(person.getName(), Collections.singleton(person.getEmailAddress()));
+  }
+
+  public static Set<String> getNameParts(String name) {
+    return getNameParts(name, Collections.emptySet());
+  }
+
+  public static Set<String> getNameParts(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(Locale.US);
+      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(Locale.US)));
+    }
+    return parts;
+  }
+
+  private SchemaUtil() {}
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/SiteIndexer.java b/gerrit-index/src/main/java/com/google/gerrit/index/SiteIndexer.java
new file mode 100644
index 0000000..9e41262
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/SiteIndexer.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.index;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.util.io.NullOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+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;
+    private final boolean success;
+    private final int done;
+    private final int failed;
+
+    public Result(Stopwatch sw, boolean success, int done, int failed) {
+      this.elapsedNanos = sw.elapsed(TimeUnit.NANOSECONDS);
+      this.success = success;
+      this.done = done;
+      this.failed = failed;
+    }
+
+    public boolean success() {
+      return success;
+    }
+
+    public int doneCount() {
+      return done;
+    }
+
+    public int failedCount() {
+      return failed;
+    }
+
+    public long elapsed(TimeUnit timeUnit) {
+      return timeUnit.convert(elapsedNanos, TimeUnit.NANOSECONDS);
+    }
+  }
+
+  protected int totalWork = -1;
+  protected OutputStream progressOut = NullOutputStream.INSTANCE;
+  protected PrintWriter verboseWriter = newPrintWriter(NullOutputStream.INSTANCE);
+
+  public void setTotalWork(int num) {
+    totalWork = num;
+  }
+
+  public void setProgressOut(OutputStream out) {
+    progressOut = checkNotNull(out);
+  }
+
+  public void setVerboseOut(OutputStream out) {
+    verboseWriter = newPrintWriter(checkNotNull(out));
+  }
+
+  public abstract Result indexAll(I index);
+
+  protected final void addErrorListener(
+      ListenableFuture<?> future, String desc, ProgressMonitor progress, AtomicBoolean ok) {
+    future.addListener(
+        new ErrorListener(future, desc, progress, ok), MoreExecutors.directExecutor());
+  }
+
+  protected PrintWriter newPrintWriter(OutputStream out) {
+    return new PrintWriter(new OutputStreamWriter(out, UTF_8));
+  }
+
+  private static class ErrorListener implements Runnable {
+    private final ListenableFuture<?> future;
+    private final String desc;
+    private final ProgressMonitor progress;
+    private final AtomicBoolean ok;
+
+    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 run() {
+      try {
+        future.get();
+      } catch (RejectedExecutionException e) {
+        // Server shutdown, don't spam the logs.
+        failSilently();
+      } 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 {
+        synchronized (progress) {
+          progress.update(1);
+        }
+      }
+    }
+
+    private void failSilently() {
+      ok.set(false);
+    }
+
+    private void fail(Throwable t) {
+      log.error("Failed to index " + desc, t);
+      ok.set(false);
+    }
+
+    private void failAndThrow(RuntimeException e) {
+      fail(e);
+      throw e;
+    }
+
+    private void failAndThrow(Error e) {
+      fail(e);
+      throw e;
+    }
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/AndPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/AndPredicate.java
new file mode 100644
index 0000000..7fba05f
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/AndPredicate.java
@@ -0,0 +1,131 @@
+// 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.index.query;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gwtorm.server.OrmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/** Requires all predicates to be true. */
+public class AndPredicate<T> extends Predicate<T> implements Matchable<T> {
+  private final List<Predicate<T>> children;
+  private final int cost;
+
+  @SafeVarargs
+  protected AndPredicate(Predicate<T>... that) {
+    this(Arrays.asList(that));
+  }
+
+  protected AndPredicate(Collection<? extends Predicate<T>> that) {
+    List<Predicate<T>> t = new ArrayList<>(that.size());
+    int c = 0;
+    for (Predicate<T> p : that) {
+      if (getClass() == p.getClass()) {
+        for (Predicate<T> gp : p.getChildren()) {
+          t.add(gp);
+          c += gp.estimateCost();
+        }
+      } else {
+        t.add(p);
+        c += p.estimateCost();
+      }
+    }
+    children = t;
+    cost = c;
+  }
+
+  @Override
+  public final List<Predicate<T>> getChildren() {
+    return Collections.unmodifiableList(children);
+  }
+
+  @Override
+  public final int getChildCount() {
+    return children.size();
+  }
+
+  @Override
+  public final Predicate<T> getChild(int i) {
+    return children.get(i);
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    return new AndPredicate<>(children);
+  }
+
+  @Override
+  public boolean isMatchable() {
+    for (Predicate<T> c : children) {
+      if (!c.isMatchable()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  @Override
+  public boolean match(T object) throws OrmException {
+    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;
+      }
+    }
+    return true;
+  }
+
+  @Override
+  public int getCost() {
+    return cost;
+  }
+
+  @Override
+  public int hashCode() {
+    return getChild(0).hashCode() * 31 + getChild(1).hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == null) {
+      return false;
+    }
+    return getClass() == other.getClass()
+        && getChildren().equals(((Predicate<?>) other).getChildren());
+  }
+
+  @Override
+  public String toString() {
+    final StringBuilder r = new StringBuilder();
+    r.append("(");
+    for (int i = 0; i < getChildCount(); i++) {
+      if (i != 0) {
+        r.append(" ");
+      }
+      r.append(getChild(i));
+    }
+    r.append(")");
+    return r.toString();
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/AndSource.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/AndSource.java
new file mode 100644
index 0000000..16620b3
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/AndSource.java
@@ -0,0 +1,200 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+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) {
+      if (err.getCause() != null) {
+        Throwables.throwIfInstanceOf(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(this::transformBuffer);
+  }
+
+  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-index/src/main/java/com/google/gerrit/index/query/DataSource.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/DataSource.java
new file mode 100644
index 0000000..77dcca2
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/DataSource.java
@@ -0,0 +1,26 @@
+// 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.index.query;
+
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+public interface DataSource<T> {
+  /** @return an estimate of the number of results from {@link #read()}. */
+  int getCardinality();
+
+  /** @return read from the database and return the results. */
+  ResultSet<T> read() throws OrmException;
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/IndexPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/IndexPredicate.java
new file mode 100644
index 0000000..7811a32
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -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.
+
+package com.google.gerrit.index.query;
+
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.FieldType;
+
+/** Index-aware predicate that includes a field type annotation. */
+public abstract class IndexPredicate<I> extends OperatorPredicate<I> {
+  private final FieldDef<I, ?> def;
+
+  protected IndexPredicate(FieldDef<I, ?> def, String value) {
+    super(def.getName(), value);
+    this.def = def;
+  }
+
+  protected IndexPredicate(FieldDef<I, ?> def, String name, String value) {
+    super(name, value);
+    this.def = def;
+  }
+
+  public FieldDef<I, ?> getField() {
+    return def;
+  }
+
+  public FieldType<?> getType() {
+    return def.getType();
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/IntPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/IntPredicate.java
new file mode 100644
index 0000000..16e59e7
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/IntPredicate.java
@@ -0,0 +1,56 @@
+// 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.index.query;
+
+/** Predicate to filter a field by matching integer value. */
+public abstract class IntPredicate<T> extends OperatorPredicate<T> {
+  private final int intValue;
+
+  public IntPredicate(String name, String value) {
+    super(name, value);
+    this.intValue = Integer.parseInt(value);
+  }
+
+  public IntPredicate(String name, int intValue) {
+    super(name, String.valueOf(intValue));
+    this.intValue = intValue;
+  }
+
+  public int intValue() {
+    return intValue;
+  }
+
+  @Override
+  public int hashCode() {
+    return getOperator().hashCode() * 31 + intValue;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == null) {
+      return false;
+    }
+    if (getClass() == other.getClass()) {
+      final IntPredicate<?> p = (IntPredicate<?>) other;
+      return getOperator().equals(p.getOperator()) && intValue() == p.intValue();
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return getOperator() + ":" + getValue();
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/IntegerRangePredicate.java
new file mode 100644
index 0000000..66351a8
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -0,0 +1,52 @@
+// 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.index.query;
+
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.RangeUtil.Range;
+import com.google.gwtorm.server.OrmException;
+
+public abstract class IntegerRangePredicate<T> extends IndexPredicate<T> {
+  private final Range range;
+
+  protected IntegerRangePredicate(FieldDef<T, Integer> type, String value)
+      throws QueryParseException {
+    super(type, value);
+    range = RangeUtil.getRange(value, Integer.MIN_VALUE, Integer.MAX_VALUE);
+    if (range == null) {
+      throw new QueryParseException("Invalid range predicate: " + value);
+    }
+  }
+
+  protected abstract Integer getValueInt(T object) throws OrmException;
+
+  public boolean match(T object) throws OrmException {
+    Integer valueInt = getValueInt(object);
+    if (valueInt == null) {
+      return false;
+    }
+    return valueInt >= range.min && valueInt <= range.max;
+  }
+
+  /** Return the minimum value of this predicate's range, inclusive. */
+  public int getMinimumValue() {
+    return range.min;
+  }
+
+  /** Return the maximum value of this predicate's range, inclusive. */
+  public int getMaximumValue() {
+    return range.max;
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/InternalQuery.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/InternalQuery.java
new file mode 100644
index 0000000..0f8948b
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/InternalQuery.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.index.query;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.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.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+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.setUserProvidedLimit(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);
+    }
+  }
+
+  /**
+   * Run multiple queries in parallel.
+   *
+   * <p>If a limit was specified using {@link #setLimit(int)}, that limit is applied to each query
+   * independently.
+   *
+   * @param queries list of queries.
+   * @return results of the queries, one list of results per input query, in the same order as the
+   *     input.
+   */
+  public List<List<T>> query(List<Predicate<T>> queries) throws OrmException {
+    try {
+      return Lists.transform(queryProcessor.query(queries), QueryResult::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-index/src/main/java/com/google/gerrit/index/query/IsVisibleToPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/IsVisibleToPredicate.java
new file mode 100644
index 0000000..9cc6f50
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/IsVisibleToPredicate.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.index.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-index/src/main/java/com/google/gerrit/index/query/LimitPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/LimitPredicate.java
new file mode 100644
index 0000000..23e0f6d
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/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.index.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-index/src/main/java/com/google/gerrit/index/query/Matchable.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/Matchable.java
new file mode 100644
index 0000000..3d07943
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/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.index.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-index/src/main/java/com/google/gerrit/index/query/NotPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/NotPredicate.java
new file mode 100644
index 0000000..750759d
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/NotPredicate.java
@@ -0,0 +1,99 @@
+// 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.index.query;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gwtorm.server.OrmException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/** Negates the result of another predicate. */
+public class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
+  private final Predicate<T> that;
+
+  protected NotPredicate(Predicate<T> that) {
+    if (that instanceof NotPredicate) {
+      throw new IllegalArgumentException("Double negation unsupported");
+    }
+    this.that = that;
+  }
+
+  @Override
+  public final List<Predicate<T>> getChildren() {
+    return Collections.singletonList(that);
+  }
+
+  @Override
+  public final int getChildCount() {
+    return 1;
+  }
+
+  @Override
+  public final Predicate<T> getChild(int i) {
+    if (i != 0) {
+      throw new ArrayIndexOutOfBoundsException(i);
+    }
+    return that;
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    if (children.size() != 1) {
+      throw new IllegalArgumentException("Expected exactly one child");
+    }
+    return new NotPredicate<>(children.iterator().next());
+  }
+
+  @Override
+  public boolean isMatchable() {
+    return that.isMatchable();
+  }
+
+  @Override
+  public boolean match(T object) throws OrmException {
+    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.estimateCost();
+  }
+
+  @Override
+  public int hashCode() {
+    return ~that.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == null) {
+      return false;
+    }
+    return getClass() == other.getClass()
+        && getChildren().equals(((Predicate<?>) other).getChildren());
+  }
+
+  @Override
+  public final String toString() {
+    return "-" + that.toString();
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/OperatorPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/OperatorPredicate.java
new file mode 100644
index 0000000..368ee24
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/